diff --git a/.eslintrc b/.eslintrc index 498b366d60..f87c8017b9 100644 --- a/.eslintrc +++ b/.eslintrc @@ -4,6 +4,9 @@ "node": true }, "parser": "@typescript-eslint/parser", + "parserOptions": { + "project": "./tsconfig.json" + }, "extends": ["@alifd/eslint-config-next", "plugin:@typescript-eslint/recommended", "prettier"], "plugins": ["@typescript-eslint", "eslint-plugin-tsdoc"], "settings": { @@ -30,10 +33,13 @@ "max-statements": "off", "max-len": "off", "no-unused-vars": "off", - "@typescript-eslint/no-unused-vars": "warn", + "@typescript-eslint/no-unused-vars": ["warn", {"ignoreRestSiblings": true}], "import/prefer-default-export": "off", "@typescript-eslint/no-explicit-any": ["error", { "ignoreRestArgs": true }], "@typescript-eslint/ban-ts-comment": "error", + "import/export": "off", + "@typescript-eslint/consistent-type-exports": "warn", + "@typescript-eslint/consistent-type-imports": "warn", "no-use-before-define": "off", "@typescript-eslint/no-use-before-define": "error", "react/no-deprecated": "error", diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml new file mode 100644 index 0000000000..902c4c567b --- /dev/null +++ b/.github/workflows/check-pr.yml @@ -0,0 +1,22 @@ +name: check + +on: [pull_request] + +jobs: + changed: + runs-on: ubuntu-latest + steps: + - name: Checkout Commit + uses: actions/checkout@v3 + with: + ref: ${{ github.event.pull_request.head.sha }} + fetch-depth: ${{ github.event.pull_request.commits }} + - name: Get Head Commit Message + id: get_head_commit_message + run: echo '${{ github.event.pull_request.base.sha }}' > /tmp/pr_base + - uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + - run: npm ci + - run: npm run check:changed diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 622979ab3e..57c719cd2d 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -23,5 +23,7 @@ jobs: repo-token: ${{ secrets.GITHUB_TOKEN }} days-before-stale: 30 start-date: '2023-10-12' + exempt-pr-labels: Technical Upgrade,hold + exempt-issue-labels: Technical Upgrade,PR welcome,Easy to solve,Sticky Topic,🐞 Bug,improvement,💡 Feature Request stale-issue-message: '你好,该 issue 已 30 天没有活动,因此被标记为 stale,如果之后的 7 天仍然没有活动,该 issue 将被自动关闭' stale-pr-message: '你好,该 pr 已 30 天没有活动,因此被标记为 stale,如果之后的 7 天仍然没有活动,该 pr 将被自动关闭' diff --git a/.github/workflows/test-legacy.yml b/.github/workflows/test-legacy.yml deleted file mode 100644 index 2abe84408d..0000000000 --- a/.github/workflows/test-legacy.yml +++ /dev/null @@ -1,18 +0,0 @@ -name: test-legacy - -on: [push, pull_request] - -jobs: - test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v3 - - - uses: actions/setup-node@v3 - with: - node-version: 16 - cache: 'npm' - - - run: npm ci - - - run: npm run test:js diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 10101d0c0e..197b0580d1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -1,9 +1,13 @@ name: test -on: [push, pull_request] +on: + pull_request: + push: + branches: + - master jobs: - test: + ts: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 @@ -19,3 +23,16 @@ jobs: - name: coverage run: bash <(curl -s https://codecov.io/bash) + js: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - uses: actions/setup-node@v3 + with: + node-version: 16 + cache: 'npm' + + - run: npm ci + + - run: npm run test:js \ No newline at end of file diff --git a/.gitignore b/.gitignore index e5db750be6..fbcac157cc 100644 --- a/.gitignore +++ b/.gitignore @@ -87,3 +87,6 @@ gemini-report/ *.log .DS_Store src/core-temp + +# tests snapshots diff +components/**/__tests__/snapshots/__diff__/** diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b9d19d85a..ecf6ad4c11 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,449 @@ # Change Log +## [1.27.31](https://github.com/alibaba-fusion/next/compare/1.27.30...1.27.31) (2025-01-06) + + +### Bug Fixes + +* **Core:** 修复使用 css variable 模式构建时,主题包内的 varMap 配置值会被重复覆盖的问题 ([#5002](https://github.com/alibaba-fusion/next/issues/5002)) ([6ed80bf](https://github.com/alibaba-fusion/next/commit/6ed80bf2a5f1eff453fe1ced2eb7a7366e719bd0)) + + +## [1.27.30](https://github.com/alibaba-fusion/next/compare/1.27.28...1.27.30) (2024-12-06) + + +### Bug Fixes + +* **Badge:** set alignment by use transform ([fbda649](https://github.com/alibaba-fusion/next/commit/fbda649d53fe8bdf080b5cea51e2841233afa34c)) +* **Balloon:** v2 default offset adjustment ([3f5f818](https://github.com/alibaba-fusion/next/commit/3f5f81882963f8dbfb716c77a75622d001803b6e)) +* **CascaderSelect:** The value of the menuProps attribute is passed by props ([7a33369](https://github.com/alibaba-fusion/next/commit/7a33369dc01195edf5da7b15f7b9f2d45aa94ceb)) +* **Collapse:** Internal elements need to apply the radius configuration of external elements, close [#3277](https://github.com/alibaba-fusion/next/issues/3277) ([936c429](https://github.com/alibaba-fusion/next/commit/936c429ae74a2568d835b5f4c57b7abd94a6194c)) +* **DatePicker2:** support defaultValue & value for quarter, close [#3006](https://github.com/alibaba-fusion/next/issues/3006) ([9760278](https://github.com/alibaba-fusion/next/commit/97602785374fe6bdedfb8f5a97639b85d0fc32d2)) +* **Select:** Fix select doc ([af1c2e7](https://github.com/alibaba-fusion/next/commit/af1c2e7943be7f40e197cac0cd0b9db3fd5d44b0)) + + +### Code Refactoring + +* **ConfigProvider:** static reference to moment ([e74c307](https://github.com/alibaba-fusion/next/commit/e74c30744907d3c4e712c90c28e31bdf972bd160)) + + +## [1.27.29](https://github.com/alibaba-fusion/next/compare/1.27.28...1.27.29) (2024-11-12) + + +### Bug Fixes + +* **Slider:** correct typo and improve types definition ([9d4f0f7](https://github.com/alibaba-fusion/next/commit/9d4f0f79efb48e0ab657862bef17d0b3040e6709)) + + +## [1.27.28](https://github.com/alibaba-fusion/next/compare/1.27.26...1.27.28) (2024-10-21) + + +### Bug Fixes + +* **DatePicker2:** after entering a customized date format and pressing Enter, the value should not change,closed [#4896](https://github.com/alibaba-fusion/next/issues/4896) ([e74ab40](https://github.com/alibaba-fusion/next/commit/e74ab40bf206ab0ad748d2c6b519972df1116810)) +* **Grid:** The style prop of Col in the Grid does not take effect ([836eeb6](https://github.com/alibaba-fusion/next/commit/836eeb65d355ff03b9d98d9819db95b82b7e8aac)) +* **Timeline:** left content of timeline item cannot be styled correctly ([f27e646](https://github.com/alibaba-fusion/next/commit/f27e646f8c3e3a72bf91992a870701226cfc9834)) + + +### Code Refactoring + +* **DatePicker:** convert to TypeScript, improve docs and tests ([4c37bf5](https://github.com/alibaba-fusion/next/commit/4c37bf5085176e2e34327e2def2e7b892dac6a2b)) +* **Form:** convert to TypeScript, improve docs and tests, close[#4585](https://github.com/alibaba-fusion/next/issues/4585) ([adbb6c9](https://github.com/alibaba-fusion/next/commit/adbb6c9f63170f4a4adc8395f3a33421c446dbcc)) +* **Form:** update form field options and dependencies ([f1c17a1](https://github.com/alibaba-fusion/next/commit/f1c17a1e5bbd68cd0e56e2334ed1a7690973899f)) +* **Range:** convert to TypeScript, improve docs and tests ([d4b99e9](https://github.com/alibaba-fusion/next/commit/d4b99e964a7d0bbd6e04b60d065ce884db0e6366)) +* **Shell:** convert to TypeScript, improve docs and tests ([202f538](https://github.com/alibaba-fusion/next/commit/202f5380026e75252d9a8267489ac1a5ff6b82f5)) +* **Tab:** convert to TypeScript, improve docs and tests ([4632a46](https://github.com/alibaba-fusion/next/commit/4632a467a77a4387c5ec8dec55a97353fa40ca33)) +* **TimePicker2:** convert to TypeScript, improve docs and tests, close [#4616](https://github.com/alibaba-fusion/next/issues/4616) ([e082513](https://github.com/alibaba-fusion/next/commit/e082513e781738fdbc27cd418cb6fa13aa06faa1)) + + +## [1.27.27](https://github.com/alibaba-fusion/next/compare/1.27.26...1.27.27) (2024-10-18) + + +### Bug Fixes + +* **Switch:** style prop ([6c5b266](https://github.com/alibaba-fusion/next/commit/6c5b2661d029d9d80a6196f1a16412dcd180cac8)) + + +## [1.27.26](https://github.com/alibaba-fusion/next/compare/1.27.25...1.27.26) (2024-09-20) + + +### Bug Fixes + +* **VirtualList:** stabilize children keys in virtual list ([f3d1d81](https://github.com/alibaba-fusion/next/commit/f3d1d81af3bf7b491919797bef3aec22984e5f0d)) + + +## [1.27.25](https://github.com/alibaba-fusion/next/compare/1.27.24...1.27.25) (2024-09-14) + + +### Bug Fixes + +* **Balloon:** export balloon props types ([eecc4dd](https://github.com/alibaba-fusion/next/commit/eecc4dda8acc5ddef9b08e26127613f83dca9d9a)) +* **Slider:** export SliderProps types ([813448d](https://github.com/alibaba-fusion/next/commit/813448de7a049679283e4d0f4f9e4a8ccbfad4d8)) + + +## [1.27.24](https://github.com/alibaba-fusion/next/compare/1.27.23...1.27.24) (2024-09-14) + + +### Bug Fixes + +* change esbuild target to es2018 ([0fe351f](https://github.com/alibaba-fusion/next/commit/0fe351feca93e678a5c9fb94fb43ea603d629fe7)) + + +## [1.27.23](https://github.com/alibaba-fusion/next/compare/1.27.22...1.27.23) (2024-09-10) + + +### Bug Fixes + +* **Form:** enabled responsive form should maintain alignment between label and input ([db30f4f](https://github.com/alibaba-fusion/next/commit/db30f4fcd94b8a583dc3b435c5a1f9a8113fe76c)) +* **Overlay:** overlay support SSR, close [#4205](https://github.com/alibaba-fusion/next/issues/4205) ([69241bc](https://github.com/alibaba-fusion/next/commit/69241bc660137e6028840b5727bba8e9f9d51989)) +* **Select:** adjust auto-complete menu length issue, close [#4873](https://github.com/alibaba-fusion/next/issues/4873) ([bf08e19](https://github.com/alibaba-fusion/next/commit/bf08e19411d7a0fe434902573279b973fa8a6382)) +* **Upload:** theme page fail to render ([a200df1](https://github.com/alibaba-fusion/next/commit/a200df1a8dfed99ada60b0bbd0b801d891a811b8)) +* **VirtualList:** resolve bugs in jumpIndex functionality, close [#4883](https://github.com/alibaba-fusion/next/issues/4883) ([324b0d3](https://github.com/alibaba-fusion/next/commit/324b0d30f49918e215142edf3a294343e182674d)) + + +### Code Refactoring + +* **Balloon:** convert to TypeScript, improve docs and tests, close[#4565](https://github.com/alibaba-fusion/next/issues/4565) ([cf6a80d](https://github.com/alibaba-fusion/next/commit/cf6a80dfd390907c07cd6e35feb18429ea350d94)) +* **Card:** convert to TypeScript, improve docs and tests, close[#4571](https://github.com/alibaba-fusion/next/issues/4571) ([15ff8b6](https://github.com/alibaba-fusion/next/commit/15ff8b6fefc2db00816dec7e28ea6a702a5007d6)) +* **Pagination:** convert to TypeScript, improve docs and tests ([442240d](https://github.com/alibaba-fusion/next/commit/442240d4aa3766bbadf9a3ef1c2c74798ce961b1)) +* **Slider:** convert to TypeScript, improve docs and tests ([0ddbb65](https://github.com/alibaba-fusion/next/commit/0ddbb65c50bea5c1029ab9534bd5d3bdb70774a4)) +* **VirtualList:** convert to TypeScript, improve docs and tests ([21518d4](https://github.com/alibaba-fusion/next/commit/21518d4a489759607fd5398053cfea5c9dfce01b)) + + +## [1.27.22](https://github.com/alibaba-fusion/next/compare/1.27.21...1.27.22) (2024-08-27) + + +### Bug Fixes + +* **Button:** add displayName for Button and ButtonGroup to fix balloon issue ([b23bead](https://github.com/alibaba-fusion/next/commit/b23bead1e18448924ad6bb0cff1e3e084af6b900)) + + +## [1.27.21](https://github.com/alibaba-fusion/next/compare/1.27.20...1.27.21) (2024-08-12) + + +### Bug Fixes + +* add displayName to components for configProvider popupContainer bug ([ced12e1](https://github.com/alibaba-fusion/next/commit/ced12e125c7495330c6f4c59cc23c0805f07b216)) + + +## [1.27.20](https://github.com/alibaba-fusion/next/compare/1.27.19...1.27.20) (2024-08-09) + + +### Bug Fixes + +* add displayName to components for configProvider popupContainer bug ([c682c03](https://github.com/alibaba-fusion/next/commit/c682c0332a79cfcf15ca8eeb6e838b42390a72ca)) + + +## [1.27.19](https://github.com/alibaba-fusion/next/compare/1.27.18...1.27.19) (2024-08-08) + + +### Bug Fixes + +* add displayName to components for configProvider locale bug ([622cc47](https://github.com/alibaba-fusion/next/commit/622cc473ae7709910e080f201bc042d9ce2330dd)) + + +## [1.27.18](https://github.com/alibaba-fusion/next/compare/1.27.17...1.27.18) (2024-08-06) + + +### Bug Fixes + +* **Breadcrumb:** fix comment issues ([03873e7](https://github.com/alibaba-fusion/next/commit/03873e720100300470f758057d7eb62cbbdf95cd)) +* **Calendar:** make switch panel unclickable when showOtherMonth is false, close [#4782](https://github.com/alibaba-fusion/next/issues/4782) ([8fd68ff](https://github.com/alibaba-fusion/next/commit/8fd68ff763ab9af3e5e1d3a5159c46a1f0a440d6)) +* **Upload:** support upload again when an upload fails, close [#4849](https://github.com/alibaba-fusion/next/issues/4849) ([1fd28f9](https://github.com/alibaba-fusion/next/commit/1fd28f9de85b5dbd56201ee357a4ab2ec148fed0)) +* **Upload:** support itemRender on Dragger, close [#4840](https://github.com/alibaba-fusion/next/issues/4840) and [#4721](https://github.com/alibaba-fusion/next/issues/4721) ([f4c56c0](https://github.com/alibaba-fusion/next/commit/f4c56c0b1773a44412c996df978ffea518d60e87)) + + +### Documentation + +* **Cascader:** Provide supplementary explanations for useVirtual ([#4885](https://github.com/alibaba-fusion/next/issues/4885)) ([a3ed8f3](https://github.com/alibaba-fusion/next/commit/a3ed8f3f95d547e1ecac697e71cfee3bb63b5cac)) + + +### Code Refactoring + +* **Breadcrumb:** upgrade tests and docs, convert to TypeScript ([997721c](https://github.com/alibaba-fusion/next/commit/997721c87cfa88f8e5ea22043496c9fa0890f4b8)) +* **Dropdown:** improve ts & docs & test ([edc7c11](https://github.com/alibaba-fusion/next/commit/edc7c118d54353706196c060de0ecd86b0a94cd5)) +* **MenuButton:** rename to ts, improve tc, types and docs ([08bf3d2](https://github.com/alibaba-fusion/next/commit/08bf3d2d969f1715bf9bb586688eadef9251517f)) +* **Overlay:** ts & docs & test tools ([bda0ef3](https://github.com/alibaba-fusion/next/commit/bda0ef3c62c8b9a491057d19e08ce7d0353234fa)) +* **TimePicker:** upgrade tests and docs, convert to TypeScript ([5ede00a](https://github.com/alibaba-fusion/next/commit/5ede00afcdbac760c0d809d4c7c57aca61b590bd)) +* **Upload:** convert to TypeScript, improve docs and tests, close [#4622](https://github.com/alibaba-fusion/next/issues/4622) ([#4841](https://github.com/alibaba-fusion/next/issues/4841)) ([26d59c9](https://github.com/alibaba-fusion/next/commit/26d59c9edc079bf19a46de1835330fa1c9682192)) + + +## [1.27.17](https://github.com/alibaba-fusion/next/compare/1.27.15...1.27.17) (2024-07-19) + + +### Bug Fixes + +* **Tree:** tree demo fail to render ([ca23b16](https://github.com/alibaba-fusion/next/commit/ca23b16ab67312324ad7b9d5348ed3e6b864b0c0)) + + +## [1.27.16](https://github.com/alibaba-fusion/next/compare/1.27.15...1.27.16) (2024-07-18) + + +### Bug Fixes + +* **Icon:** icon demo fail to render ([7bf33dd](https://github.com/alibaba-fusion/next/commit/7bf33ddc77cdef615dc05ff0e730f36547ead243)) + + +## [1.27.15](https://github.com/alibaba-fusion/next/compare/1.27.14...1.27.15) (2024-07-05) + + +### Features + +* **Tree:** the Tree component provides the scrollFilterNodeIntoView API ([#4860](https://github.com/alibaba-fusion/next/issues/4860)) ([9df35fe](https://github.com/alibaba-fusion/next/commit/9df35fea5efdd7b2b87926f308eb7e4f7eefafd0)) + + +### Bug Fixes + +* **DatePicker2:** unable to enter space to enter time ([65faba2](https://github.com/alibaba-fusion/next/commit/65faba254b349ead3642b3efafab67023ed98a07)) + + +## [1.27.14](https://github.com/alibaba-fusion/next/compare/1.27.13...1.27.14) (2024-07-03) + + +### Features + +* **Balloon:** add mouseEnterDelay and mouseLeaveDelay, with higher priority than delay ([1f6ccdd](https://github.com/alibaba-fusion/next/commit/1f6ccdd7a3aa7337946055b1a41b5889cdc82f60)) +* **CascaderSelect:** clear the search box after selecting the item, close [#3008](https://github.com/alibaba-fusion/next/issues/3008) [#3415](https://github.com/alibaba-fusion/next/issues/3415) ([9e159f2](https://github.com/alibaba-fusion/next/commit/9e159f26ddb0c4e575be00e503529c5d6da74fde)) + + +### Bug Fixes + +* **CascaderSelect:** use white background with virtual scrolling ([65a206b](https://github.com/alibaba-fusion/next/commit/65a206bc79a191c9c4bf72388861c6ebf629b11a)) +* **DatePicker2:** okButton is still clickable when input value is in disabled date, close [#3801](https://github.com/alibaba-fusion/next/issues/3801) ([#4870](https://github.com/alibaba-fusion/next/issues/4870)) ([df4ecc7](https://github.com/alibaba-fusion/next/commit/df4ecc742b0f0646a69f2387980c0dc655c7ec5e)) +* **DatePicker2:** unable to enter space to enter time ([83269e0](https://github.com/alibaba-fusion/next/commit/83269e0ad3c02c921072ab16470d46a19bcdf9f0)) +* **Message:** the styles of the outer Message component will affect the inner component when nested, close [#4851](https://github.com/alibaba-fusion/next/issues/4851) ([ec8978e](https://github.com/alibaba-fusion/next/commit/ec8978e223078fb32d45d2c499bd59d07dabe63d)) +* **Select:** should be able to not clear search value in multiple mode, close [#3590](https://github.com/alibaba-fusion/next/issues/3590) ([ab017bc](https://github.com/alibaba-fusion/next/commit/ab017bc1358ca7cee2b1dfba92ecda3a4dbca9dd)) +* **Slider:** the last element covers on top when the Slider component is set to fade effect ([28a6b23](https://github.com/alibaba-fusion/next/commit/28a6b235c10790f676f33bf221f4623c7c62bc3f)) +* **Tree:** should keep click and key events consistent,close [#2899](https://github.com/alibaba-fusion/next/issues/2899) ([906bc90](https://github.com/alibaba-fusion/next/commit/906bc90893fecfb0971ee2e0fc4b7be324a469b8)) + + +### Documentation + +* update contributing document ([740d523](https://github.com/alibaba-fusion/next/commit/740d523f6d9c7115c66c498857f95b4fd2a2c781)) + + +## [1.27.13](https://github.com/alibaba-fusion/next/compare/1.27.12...1.27.13) (2024-06-11) + + +### Bug Fixes + +* **Select:** remove duplicate values when selecting all options in multiple mode ([aed4c97](https://github.com/alibaba-fusion/next/commit/aed4c9721ab7108fe90fadb325a718f760e1fd5d)) + + +## [1.27.12](https://github.com/alibaba-fusion/next/compare/1.27.11...1.27.12) (2024-06-11) + + +### Bug Fixes + +* **DatePicker2:** disabledDate method should return the correct panel mode, close [#4775](https://github.com/alibaba-fusion/next/issues/4775) ([4556613](https://github.com/alibaba-fusion/next/commit/455661388d866f4549a6312126b0b29d133aaac8)) +* **DatePicker2:** Translation compliant with international standards ([69a22e7](https://github.com/alibaba-fusion/next/commit/69a22e7cd38d8eee05a9592f1ff0bc1747582c62)) +* **DatePicker:** handle value type consistency with Form components, close [#2895](https://github.com/alibaba-fusion/next/issues/2895) ([25083c8](https://github.com/alibaba-fusion/next/commit/25083c846023e614548d9b8600a67504b8f76c5f)) +* **Input:** fix autoprefixer warning ([#4822](https://github.com/alibaba-fusion/next/issues/4822)) ([3ac4938](https://github.com/alibaba-fusion/next/commit/3ac4938dd1dd8a42ef334ede9ece232c7fa1dad7)) +* **Locale:** failed test which caused by new DatePicker key monthBeforeYear ([fc42b72](https://github.com/alibaba-fusion/next/commit/fc42b7294ccc020aabab908e2b1741479092f468)) +* **NumberPicker:** adjust type behavior for phone/tablet devices ([0470479](https://github.com/alibaba-fusion/next/commit/0470479203c811cd3f08ce26dbfc2787da264f0a)) +* **Range:** add context menu event handling to prevent default behavior, close [#4768](https://github.com/alibaba-fusion/next/issues/4768) ([38013ed](https://github.com/alibaba-fusion/next/commit/38013ed124a4a403449704008b95645f96a9ebbd)) +* **Select:** preserve previous data when selecting all, close [#4810](https://github.com/alibaba-fusion/next/issues/4810) ([a23fba8](https://github.com/alibaba-fusion/next/commit/a23fba8cd9726261e6938751905e2674270685ba)) +* **Tab:** extra content should not be obscured ([e66d4e6](https://github.com/alibaba-fusion/next/commit/e66d4e6c53a716f3d4421b1bd787c0d41698eaa2)) + + +### Code Refactoring + +* **Calendar2:** convert to TypeScript, improve docs and tests ([75a0a4a](https://github.com/alibaba-fusion/next/commit/75a0a4a3bd30a80a5af36967a33d2011e6650504)) +* **Calendar:** convert to TypeScript, improve docs and tests ([dfb0397](https://github.com/alibaba-fusion/next/commit/dfb0397fff6cb4dfbf8f273beb548da10362c86a)) +* **CascaderSelect:** convert to TypeScript, improve docs and tests ([c38ad58](https://github.com/alibaba-fusion/next/commit/c38ad582e0bf13fb55e81a041d2e96180b5938b9)) +* **Icon:** convert to TypeScript, improve docs and tests ([84d7380](https://github.com/alibaba-fusion/next/commit/84d7380215e56a2ac4bacc34345fae6a007aa214)) +* **Input:** convert to TypeScript, improve docs and tests ([f82ed20](https://github.com/alibaba-fusion/next/commit/f82ed2077931c4216b430bc41df0d926b0090a26)) +* **List:** convert to TypeScript, improve docs and tests ([445b23d](https://github.com/alibaba-fusion/next/commit/445b23d7c023adbc1f0646d63534093203451e26)) +* **Menu:** convert to TypeScript, impove docs and tests, close [#4591](https://github.com/alibaba-fusion/next/issues/4591) ([ad4885e](https://github.com/alibaba-fusion/next/commit/ad4885e9cdfe1703e987fc3193de73d452da2bdd)) +* **Message:** convert to TypeScript, improve docs and tests ([7def294](https://github.com/alibaba-fusion/next/commit/7def2940336d569c9e403e18af42111983ac2f4f)) +* **NumberPicker:** convert to TypeScript, improve docs and tests ([726ec1f](https://github.com/alibaba-fusion/next/commit/726ec1f1a07057e89f02b1f0bedbabf88e0ecb24)) +* **Progress:** convert to TypeScript, improve docs and tests ([020d6e2](https://github.com/alibaba-fusion/next/commit/020d6e205eda8b78be0c26d1e7be0b297680599f)) +* **ResponsiveGird:** rename to ts & upgrade document & refactor unit testing ([e7a0d6a](https://github.com/alibaba-fusion/next/commit/e7a0d6ab318bc324456d7f96080cea452f42c110)) +* **Select:** convert to TypeScript, improve docs and tests ([176f335](https://github.com/alibaba-fusion/next/commit/176f335cf6f0db218b5bd10075132f9730931656)) +* **Transfer:** convert to TypeScript, improve docs and tests ([129f41f](https://github.com/alibaba-fusion/next/commit/129f41f64f0b0e918b9a86be4ae752cbac7d1f06)) +* **TreeSelect:** convert to TypeScript, improve docs and tests, close [#4620](https://github.com/alibaba-fusion/next/issues/4620) ([#4837](https://github.com/alibaba-fusion/next/issues/4837)) ([9f7f9cb](https://github.com/alibaba-fusion/next/commit/9f7f9cbe6ba3f8ff9119ee1fff52c647734b6652)) + + +## [1.27.11](https://github.com/alibaba-fusion/next/compare/1.27.10...1.27.11) (2024-04-12) + + +### Bug Fixes + +* **Switch:** form fail to pass checked to Switch by wrong displayName, close [#4819](https://github.com/alibaba-fusion/next/issues/4819) ([c178333](https://github.com/alibaba-fusion/next/commit/c178333e6bd513ea26b355583eaeabe727725c46)) + + +### Code Refactoring + +* **Nav:** rename to ts & upgrade document & refactor unit testing ([2073475](https://github.com/alibaba-fusion/next/commit/2073475af57f437bd0f6054805d95fb9a514f528)) +* **Radio:** convert to TypeScript, impove docs and tests, close [#4556](https://github.com/alibaba-fusion/next/issues/4556) ([f39cffc](https://github.com/alibaba-fusion/next/commit/f39cffc232ad4a1cc7f930c45fac035784656993)) + + +## [1.27.10](https://github.com/alibaba-fusion/next/compare/1.27.9...1.27.10) (2024-04-03) + + +### Features + +* **Avatar:** support imgProps, close [#3476](https://github.com/alibaba-fusion/next/issues/3476) ([#4799](https://github.com/alibaba-fusion/next/issues/4799)) ([0045f5b](https://github.com/alibaba-fusion/next/commit/0045f5bbc476b3f2b7a82bbdac8ee127ba55b7b2)) +* **CascaderSelect:** support focus() and keyboard control, close [#3608](https://github.com/alibaba-fusion/next/issues/3608) ([66437cd](https://github.com/alibaba-fusion/next/commit/66437cd89c30855109047bf7011494f9224b6654)) + + +### Bug Fixes + +* **Grid:** pass legal HTML props to the root dom node, close [#2867](https://github.com/alibaba-fusion/next/issues/2867) ([3b60374](https://github.com/alibaba-fusion/next/commit/3b60374da95661120ba14ddcfc91d16fb2a7452b)) +* **Step:** compatible with legacy direction and labelPlacement, close [#4813](https://github.com/alibaba-fusion/next/issues/4813) ([#4814](https://github.com/alibaba-fusion/next/issues/4814)) ([fd14601](https://github.com/alibaba-fusion/next/commit/fd14601350b1c76961f039584319bc5480d5c29f)) +* **TimePicker2:** use props.format to validate value, close [#3651](https://github.com/alibaba-fusion/next/issues/3651) ([#4803](https://github.com/alibaba-fusion/next/issues/4803)) ([aac4730](https://github.com/alibaba-fusion/next/commit/aac4730c694b08412af2844a6373f437f6c80eed)) + + +### Code Refactoring + +* **Rating:** convert to TypeScript, impove docs and tests, close [#4603](https://github.com/alibaba-fusion/next/issues/4603) ([7d72565](https://github.com/alibaba-fusion/next/commit/7d7256526062746b8db68c0f29db372114474c7f)) +* **Switch:** convert to TypeScript, impove docs and tests, close [#4611](https://github.com/alibaba-fusion/next/issues/4611) ([334c8cc](https://github.com/alibaba-fusion/next/commit/334c8cc689d72e079dd9fbdf448a6f91558d7f9e)) +* **Tree:** convert to TypeScript, impove docs and tests, close [#4619](https://github.com/alibaba-fusion/next/issues/4619) ([b42bd2e](https://github.com/alibaba-fusion/next/commit/b42bd2e4664afd9148c4f2a47254dbb4ed0ec7f4)) + + +## [1.27.9](https://github.com/alibaba-fusion/next/compare/1.27.8...1.27.9) (2024-03-26) + + +### Bug Fixes + +* **Overlay:** fix maximum loop update error ([f6a71fd](https://github.com/alibaba-fusion/next/commit/f6a71fd01e5635888140e5a87042ca9ad88a5afd)) + + +### Code Refactoring + +* **SplitButton:** convert to TypeScript, impove docs and tests, close [#4609](https://github.com/alibaba-fusion/next/issues/4609) ([cc68da8](https://github.com/alibaba-fusion/next/commit/cc68da8b3233b4e29e47275ba92f5e57f0e3f106)) +* **Tag:** convert to TypeScript, impove docs and tests, close [#4614](https://github.com/alibaba-fusion/next/issues/4614) ([df9fd5e](https://github.com/alibaba-fusion/next/commit/df9fd5e4b342ecdb7ed82adec7e79f0b0f020f2f)) + + +## [1.27.8](https://github.com/alibaba-fusion/next/compare/1.27.7...1.27.8) (2024-03-21) + + +### Features + +* **Select:** suport colorful tag [#3778](https://github.com/alibaba-fusion/next/issues/3778) ([6f73852](https://github.com/alibaba-fusion/next/commit/6f73852e47f593f1b4e722aeb0c5b8a0c67ad280)) + + +### Bug Fixes + +* **Avatar:** size should work in box , close [#3511](https://github.com/alibaba-fusion/next/issues/3511) ([881cad8](https://github.com/alibaba-fusion/next/commit/881cad8d2f13c015e329e935c852e434bdcdec2d)) +* **DatePicker2:** should pass inputProps to trigger function to support custom range picker trigger ([2bdb937](https://github.com/alibaba-fusion/next/commit/2bdb937c73b3de4caccbec2fefcb6373322909e7)) +* **DatePicker2:** WeekPicker should format value correctly when date is 01-01 ([#4786](https://github.com/alibaba-fusion/next/issues/4786)) ([103aafe](https://github.com/alibaba-fusion/next/commit/103aafeafc4a0491496670dd5869e3717ef707ce)) +* **DatePicker:** should show clear icon when it only has start/end value, close [#3448](https://github.com/alibaba-fusion/next/issues/3448) ([67cf979](https://github.com/alibaba-fusion/next/commit/67cf97945a2369b3277f83c2a8eb32cc7d80ffe2)) +* **Table:** should not log warn when primaryKey is 0, close [#3740](https://github.com/alibaba-fusion/next/issues/3740) ([f7b8c8c](https://github.com/alibaba-fusion/next/commit/f7b8c8c17318cfb3a7ec718d047fefddd52f0986)) +* remove banner msg from dist/*.css to make [@charset](https://github.com/charset) useful ([77b0c2e](https://github.com/alibaba-fusion/next/commit/77b0c2e35af4e5d77e78f89a18eb533af2f645be)) + + +### Documentation + +* **Search:** remove unnecessary symbol "," ([4c15a1b](https://github.com/alibaba-fusion/next/commit/4c15a1bd97bf46ffa902908b1b5ad394788812ad)) + + +### Code Refactoring + +* **Checkbox:** convert to TypeScript, impove docs and tests ([#4688](https://github.com/alibaba-fusion/next/pull/4688)) +* **Dialog:** convert to TypeScript, impove docs and tests ([#4772](https://github.com/alibaba-fusion/next/pull/4772)) +* **Drawer:** convert to TypeScript, impove docs and tests ([#4760](https://github.com/alibaba-fusion/next/pull/4760)) +* **Step:** convert to TypeScript, impove docs and tests ([#4770](https://github.com/alibaba-fusion/next/pull/4770)) + + +## [1.27.7](https://github.com/alibaba-fusion/next/compare/1.27.6...1.27.7) (2024-03-08) + + +### Bug Fixes + +* **Collapse:** hotfix panel className missing ([8430d71](https://github.com/alibaba-fusion/next/commit/8430d71ab58a13024b17a20298d2e7cef50ce9ad)) + + +## [1.27.6](https://github.com/alibaba-fusion/next/compare/1.27.5...1.27.6) (2024-03-07) + + +### Features + +* **DatePicker:** improve focus logic, close [#3998](https://github.com/alibaba-fusion/next/issues/3998) ([#4769](https://github.com/alibaba-fusion/next/issues/4769)) ([1cdd236](https://github.com/alibaba-fusion/next/commit/1cdd236486305ff8940498d24f7f396d46a40746)) +* **TreeSelect:** support useDetailValue, close [#3531](https://github.com/alibaba-fusion/next/issues/3531) ([#4771](https://github.com/alibaba-fusion/next/issues/4771)) ([d19ebdd](https://github.com/alibaba-fusion/next/commit/d19ebdd78c699f8e525a8694a6599f18ff0beec2)) + + +### Bug Fixes + +* **Shell:** phone shell should hidden when collapsed, close [#3886](https://github.com/alibaba-fusion/next/issues/3886) ([#4766](https://github.com/alibaba-fusion/next/issues/4766)) ([94d3030](https://github.com/alibaba-fusion/next/commit/94d3030682a64de37f5a62b6766ac46b3b209695)) +* **Table:** fix merging cell width in locked columns, close [#4716](https://github.com/alibaba-fusion/next/issues/4716) ([#4752](https://github.com/alibaba-fusion/next/issues/4752)) ([9bda719](https://github.com/alibaba-fusion/next/commit/9bda719c7dae5e47d7f3ade3ef05cd00ca5a11b1)) +* **Upload:** should hide trigger when limit is reached for Upload.Dragger, close [#3951](https://github.com/alibaba-fusion/next/issues/3951) ([#4761](https://github.com/alibaba-fusion/next/issues/4761)) ([f2d5303](https://github.com/alibaba-fusion/next/commit/f2d5303214984891cd8b638c43f1005d21364f7d)) + + +### Documentation + +* **Calendar2:** remove legacy api, close [#3100](https://github.com/alibaba-fusion/next/issues/3100) ([8a6536f](https://github.com/alibaba-fusion/next/commit/8a6536fdb4b0fe83756ed2b1e1e8f40953401da4)) +* **Field:** improve document description of parseName, close [#3453](https://github.com/alibaba-fusion/next/issues/3453) ([004fa0e](https://github.com/alibaba-fusion/next/commit/004fa0e9e3ad085209859e5eb78b76caaf48ad3e)) + + +### Code Refactoring + +* **Collapse:** convert to TypeScript, impove docs and tests ([#4713](https://github.com/alibaba-fusion/next/pull/4713)) +* **Field:** convert to TypeScript, impove docs and tests ([#4710](https://github.com/alibaba-fusion/next/pull/4710)) +* **Timeline:** convert to TypeScript, impove docs and tests ([#4715](https://github.com/alibaba-fusion/next/pull/4715)) + + +## [1.27.5](https://github.com/alibaba-fusion/next/compare/1.27.4...1.27.5) (2024-02-22) + +### Bug Fixes + +* **ConfigProvider:** improve config types, close [#4751](https://github.com/alibaba-fusion/next/issues/4751) ([b442d93](https://github.com/alibaba-fusion/next/commit/b442d9310bf503203ba4cc36ac6fb5766f030289)) +* **TimePicker2:** should has focus style when visible, close [#4657](https://github.com/alibaba-fusion/next/issues/4657) ([#4738](https://github.com/alibaba-fusion/next/issues/4738)) ([228b621](https://github.com/alibaba-fusion/next/commit/228b621023fb8e63d79b5783393954b8a6e12db5)) +* **Overlay:** solve problems caused by numerical floating, close [#4740](https://github.com/alibaba-fusion/next/issues/4740) ([8f29094](https://github.com/alibaba-fusion/next/commit/8f290948b08d6fda23121f6c75178b738b2b84c2)) +* rollback [#4746](https://github.com/alibaba-fusion/next/issues/4746) and fix textarea clear spec ([e486542](https://github.com/alibaba-fusion/next/commit/e486542d786f63ce189adc8c1908f782a2082a03)) + + +### Code Refactoring + +* **Cascader:** convert to TypeScript, impove docs and tests ([#4730](https://github.com/alibaba-fusion/next/pull/4730)) +* **Grid:** convert to TypeScript, impove docs and tests ([#4703](https://github.com/alibaba-fusion/next/pull/4703)) +* **List:** convert to TypeScript, impove docs and tests ([#4702](https://github.com/alibaba-fusion/next/pull/4702)) +* **Validate:** convert to TypeScript, improve tests ([910c957](https://github.com/alibaba-fusion/next/commit/910c957fc9623f642c5400f680af497bc1e5c4c6)) + + +## [1.27.5-beta.1](https://github.com/alibaba-fusion/next/compare/1.27.5-beta.0...1.27.5-beta.1) (2024-02-22) + + +## [1.27.5-beta.0](https://github.com/alibaba-fusion/next/compare/1.27.4...1.27.5-beta.0) (2024-02-22) + + +## [1.27.4](https://github.com/alibaba-fusion/next/compare/1.27.3...1.27.4) (2024-01-26) + + +### Bug Fixes + +* **Util:** compatibility breaking introduced by Object.hasOwn ([6caea8e](https://github.com/alibaba-fusion/next/commit/6caea8e7a4c2ea5f4bd0ed3cd124b1cb074e12e4)) + + +## [1.27.3](https://github.com/alibaba-fusion/next/compare/1.27.2...1.27.3) (2024-01-25) + + +### Features + +* **Input:** TextArea support hasClear, close [#4334](https://github.com/alibaba-fusion/next/issues/4334) ([#4714](https://github.com/alibaba-fusion/next/issues/4714)) ([12333ed](https://github.com/alibaba-fusion/next/commit/12333ed583b641a2e390cf5640b2506b731c816f)) + + +### Bug Fixes + +* **Menu:** should update layout when children size changed, close [#4640](https://github.com/alibaba-fusion/next/issues/4640) ([#4722](https://github.com/alibaba-fusion/next/issues/4722)) ([f4ceaf7](https://github.com/alibaba-fusion/next/commit/f4ceaf77c6ae171cb1e59fb29d223eafefffc82a)) +* **Overlay:** fix the crash issue when resize is caused by adjustment, close [#4692](https://github.com/alibaba-fusion/next/issues/4692) ([7315f9e](https://github.com/alibaba-fusion/next/commit/7315f9ed9343f6b9b9157c915c36a372b2219012)) +* **Rating:** fix grade background, close [#4734](https://github.com/alibaba-fusion/next/issues/4734) ([#4735](https://github.com/alibaba-fusion/next/issues/4735)) ([54d7f57](https://github.com/alibaba-fusion/next/commit/54d7f573509f7f3fbcc5344bc2cf833d9dcad709)) +* **Tree:** expand action area should not shrink when the content is oversize, close [#4689](https://github.com/alibaba-fusion/next/issues/4689) ([#4723](https://github.com/alibaba-fusion/next/issues/4723)) ([157835f](https://github.com/alibaba-fusion/next/commit/157835f992e370648287123ecdc1d492e6b07fe9)) + + +### Documentation + +* Generate docs from tsdoc ([f89d6b2](https://github.com/alibaba-fusion/next/commit/f89d6b29b483547c9c68f21d5cf4aa2819fc92f3)) + + +### Code Refactoring + +* **Animate:** convert to TypeScript, impove docs and tests ([#4719](https://github.com/alibaba-fusion/next/pull/4719)) +* **Box:** convert to TypeScript, impove docs and tests ([e1805e2](https://github.com/alibaba-fusion/next/commit/e1805e251309a7f63214ef62670b663a1a55a189)) +* **Core:** support node&sass tests, complete types, improve tests and docs ([9b1b054](https://github.com/alibaba-fusion/next/commit/9b1b054f3e61de6a073f5be198aca47e4078adc9)) +* **MixinUiState:** convert to TypeScript, improve tests ([86e5414](https://github.com/alibaba-fusion/next/commit/86e5414e6652959e9ad3efa1e92631675edeeb91)) +* **Search:** convert to TypeScript, impove docs and tests ([5997230](https://github.com/alibaba-fusion/next/commit/5997230f8a06f91341ade3e10049b6b44a915502)) + + ## [1.27.2](https://github.com/alibaba-fusion/next/compare/1.27.1...1.27.2) (2024-01-11) diff --git a/LATESTLOG.md b/LATESTLOG.md index 6bf579ba66..f8f5f5d6d3 100644 --- a/LATESTLOG.md +++ b/LATESTLOG.md @@ -1,17 +1,9 @@ # Latest Log -## [1.27.2](https://github.com/alibaba-fusion/next/compare/1.27.1...1.27.2) (2024-01-11) - - -### Features - -* **Rating:** add tip info background token, close [#4705](https://github.com/alibaba-fusion/next/issues/4705) ([#4706](https://github.com/alibaba-fusion/next/issues/4706)) ([2f71df9](https://github.com/alibaba-fusion/next/commit/2f71df9b18126487b880de595b269b917f23ff7b)) +## [1.27.31](https://github.com/alibaba-fusion/next/compare/1.27.30...1.27.31) (2025-01-06) ### Bug Fixes -* **DatePicker2:** default current day when next range is empty, close [#3877](https://github.com/alibaba-fusion/next/issues/3877) ([#4711](https://github.com/alibaba-fusion/next/issues/4711)) ([62eec1b](https://github.com/alibaba-fusion/next/commit/62eec1b92c8312a03f6cba9292249aff147c8cc8)) -* **Field:** compatible for Firefox, close [#4288](https://github.com/alibaba-fusion/next/issues/4288) ([#4712](https://github.com/alibaba-fusion/next/issues/4712)) ([b109dce](https://github.com/alibaba-fusion/next/commit/b109dceb74c91a4b3c1ae2faf933321cb738c961)) -* **Form:** pass device to responsive-grid, close [#4513](https://github.com/alibaba-fusion/next/issues/4513) ([#4707](https://github.com/alibaba-fusion/next/issues/4707)) ([110937b](https://github.com/alibaba-fusion/next/commit/110937ba84255a888f77ca30a349caa26c239d2f)) -* **Table:** fix merge width calculation logic when lock, close [#4264](https://github.com/alibaba-fusion/next/issues/4264) ([#4709](https://github.com/alibaba-fusion/next/issues/4709)) ([ed36371](https://github.com/alibaba-fusion/next/commit/ed36371b02acc1171c3abaa94e37be4e286d975d)) +* **Core:** 修复使用 css variable 模式构建时,主题包内的 varMap 配置值会被重复覆盖的问题 ([#5002](https://github.com/alibaba-fusion/next/issues/5002)) ([6ed80bf](https://github.com/alibaba-fusion/next/commit/6ed80bf2a5f1eff453fe1ced2eb7a7366e719bd0)) diff --git a/README.md b/README.md index c0e8f01d5b..a8d453a451 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ English | [简体中文](./README.zh-cn.md) +

You can customize your own DesignSystem via [Collaboration Platform](https://fusion.design).💖 Designers will receive design materials by [Fusion Cool](https://fusion.design/tool?from=github) - an easy to use plugin on sketch. Developers will get code fragment on [IceWorks](https://fusion.design/tool?from=github). At the same time, the consistency between code and visual manuscript is guaranteed. 😍 diff --git a/components/affix/__docs__/index.en-us.md b/components/affix/__docs__/index.en-us.md index e0c1a53a3d..6bf21ab918 100644 --- a/components/affix/__docs__/index.en-us.md +++ b/components/affix/__docs__/index.en-us.md @@ -17,10 +17,12 @@ The Affix component allows an element to become affixed (locked) to an area on t ### Affix -| Param | Description | Type | Default Value | -| ------------- | ------------------------------------------------------------------------------------------------------------------- | -------- | ------------ | -| container | The container for listening scroll events

**signature**:
Function() => ReactElement
**return**:
{ReactElement} the instance of container
| Function | () => window | -| offsetTop | Offset from top when event triggers | Number | - | -| offsetBottom | Offset from bottom when event triggers | Number | - | -| onAffix | Callback when affix event triggers

**signature**:
Function(isAffixed: Boolean) => void
**parameters**:
_if element is affixed_: {Boolean} null | Function | func.noop | -| useAbsolute | Enable absolute position | Boolean | - | +| Param | Description | Type | Default Value | Required | +| ------------ | --------------------------------------------------------------------------------------------------------------- | -------------------------- | ------------- | -------- | +| container | The container for listening scroll events | () => Element \| Window | () =\> window | | +| offsetTop | Offset from top when event triggers | number | - | | +| offsetBottom | Offset from bottom when event triggers | number | - | | +| onAffix | Callback when affix event triggers

**signature**:
**params**:
_affixed_: If element is affixed | (affixed: boolean) => void | - | | +| useAbsolute | Enable absolute position | boolean | - | | +| className | - | string | - | | +| style | - | React.CSSProperties | - | | diff --git a/components/affix/__docs__/index.md b/components/affix/__docs__/index.md index a97d4b2193..ace11ba91d 100644 --- a/components/affix/__docs__/index.md +++ b/components/affix/__docs__/index.md @@ -18,10 +18,12 @@ ### Affix -| 参数 | 说明 | 类型 | 默认值 | -| ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | -------- | ------------ | -| container | 设置 Affix 需要监听滚动事件的容器元素 | Function | () => window | -| offsetTop | 距离窗口顶部达到指定偏移量后触发 | Number | - | -| offsetBottom | 距离窗口底部达到制定偏移量后触发 | Number | - | -| onAffix | 当元素的样式发生固钉样式变化时触发的回调函数

**签名**:
Function(affixed: Boolean) => void
**参数**:
_affixed_: {Boolean} 元素是否被固钉 | Function | func.noop | -| useAbsolute | 是否启用绝对布局实现 affix | Boolean | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------------ | ----------------------------------------------------------------------------------------------------- | -------------------------- | ------------- | -------- | +| container | 设置 Affix 需要监听滚动事件的容器元素

**签名**:
**返回值**:
目标容器元素 | () => Element \| Window | () =\> window | | +| offsetTop | 距离窗口顶部达到指定偏移量后触发 | number | - | | +| offsetBottom | 距离窗口底部达到指定偏移量后触发 | number | - | | +| onAffix | 当元素的样式发生固钉样式变化时触发的回调函数

**签名**:
**参数**:
_affixed_: 是否固定 | (affixed: boolean) => void | - | | +| useAbsolute | 是否启用绝对布局实现 affix | boolean | - | | +| className | 包裹 children 容器的类名 | string | - | | +| style | 最外层容器的 style 样式 | React.CSSProperties | - | | diff --git a/components/animate/__docs__/index.en-us.md b/components/animate/__docs__/index.en-us.md index eec2b14336..ee3e217cdc 100644 --- a/components/animate/__docs__/index.en-us.md +++ b/components/animate/__docs__/index.en-us.md @@ -16,39 +16,39 @@ Need to customize animation. ### Animate -| Param | Description | Type | Default Value | Required | -| --------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------- | ------------- | -------- | -| animation | The animation className | string \| Partial> | - | | -| animationAppear | Whether to execute animation on the first mount | boolean | true | | -| component | The tag of the wrapper | React.ElementType | 'div' | | -| singleMode | Whether to only have a single child | boolean | true | | -| beforeAppear | Callback fired before the "entering" status of the first mount is applied | (node: HTMLElement) => void | - | | -| onAppear | Callback fired after the "entering" status of the first mount is applied | (node: HTMLElement) => void | - | | -| afterAppear | Callback fired after the "entered" status of the first mount is applied | (node: HTMLElement) => void | - | | -| beforeEnter | Callback fired before the "entering" status is applied | (node: HTMLElement) => void | - | | -| onEnter | Callback fired after the "entering" status is applied | (node: HTMLElement) => void | - | | -| afterEnter | Callback fired after the "entered" status is applied | (node: HTMLElement) => void | - | | -| beforeLeave | Callback fired before the "exiting" status is applied | (node: HTMLElement) => void | - | | -| onLeave | Callback fired after the leave stage | (node: HTMLElement) => void | - | | -| afterLeave | Callback fired after the leave stage | (node: HTMLElement) => void | - | | +| Param | Description | Type | Default Value | Required | +| --------------- | ------------------------------------------------------------------------- | ------------------------------------------------------------------- | ------------- | -------- | +| animation | The animation className | string \| Partial\> | - | | +| animationAppear | Whether to execute animation on the first mount | boolean | true | | +| component | The tag of the wrapper | React.ElementType | 'div' | | +| singleMode | Whether to only have a single child | boolean | true | | +| beforeAppear | Callback fired before the "entering" status of the first mount is applied | (node: HTMLElement) => void | - | | +| onAppear | Callback fired after the "entering" status of the first mount is applied | (node: HTMLElement) => void | - | | +| afterAppear | Callback fired after the "entered" status of the first mount is applied | (node: HTMLElement) => void | - | | +| beforeEnter | Callback fired before the "entering" status is applied | (node: HTMLElement) => void | - | | +| onEnter | Callback fired after the "entering" status is applied | (node: HTMLElement) => void | - | | +| afterEnter | Callback fired after the "entered" status is applied | (node: HTMLElement) => void | - | | +| beforeLeave | Callback fired before the "exiting" status is applied | (node: HTMLElement) => void | - | | +| onLeave | Callback fired after the leave stage | (node: HTMLElement) => void | - | | +| afterLeave | Callback fired after the leave stage | (node: HTMLElement) => void | - | | ### Animate.Expand -| Param | Description | Type | Default Value | Required | -| ----------- | ------------------------------------------------------ | ----------------------------------------------------------------- | ------------- | -------- | -| animation | The animation className | string \| Partial> | - | | -| beforeEnter | Callback fired before the "entering" status is applied | (node: HTMLElement) => void | - | | -| onEnter | Callback fired after the "entering" status is applied | (node: HTMLElement) => void | - | | -| afterEnter | Callback fired after the "entered" status is applied | (node: HTMLElement) => void | - | | -| beforeLeave | Callback fired before the "exiting" status is applied | (node: HTMLElement) => void | - | | -| onLeave | Callback fired after the "exiting" status is applied | (node: HTMLElement) => void | - | | -| afterLeave | Callback fired after the "exited" status is applied | (node: HTMLElement) => void | - | | +| Param | Description | Type | Default Value | Required | +| ----------- | ------------------------------------------------------ | ------------------------------------------------------------------- | ------------- | -------- | +| animation | The animation className | string \| Partial\> | - | | +| beforeEnter | Callback fired before the "entering" status is applied | (node: HTMLElement) => void | - | | +| onEnter | Callback fired after the "entering" status is applied | (node: HTMLElement) => void | - | | +| afterEnter | Callback fired after the "entered" status is applied | (node: HTMLElement) => void | - | | +| beforeLeave | Callback fired before the "exiting" status is applied | (node: HTMLElement) => void | - | | +| onLeave | Callback fired after the "exiting" status is applied | (node: HTMLElement) => void | - | | +| afterLeave | Callback fired after the "exited" status is applied | (node: HTMLElement) => void | - | | ### Animate.OverlayAnimate | Param | Description | Type | Default Value | Required | | ------------- | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------- | -------- | -| animation | The animation className | string \| false \| Record<'in' \| 'out', string> | - | | +| animation | The animation className | string \| false \| Record\<'in' \| 'out', string> | - | | | visible | Show the component; triggers the enter or exit states | boolean | - | | | children | The element to be wrapped | ReactElement | - | yes | | timeout | The duration of the transition. | \| number
\| { appear?: number \| undefined; enter?: number \| undefined; exit?: number \| undefined } | - | | diff --git a/components/animate/__docs__/index.md b/components/animate/__docs__/index.md index 004280fa11..adf06b22a9 100644 --- a/components/animate/__docs__/index.md +++ b/components/animate/__docs__/index.md @@ -16,39 +16,39 @@ ### Animate -| 参数 | 说明 | 类型 | 默认值 | 是否必填 | -| --------------- | --------------------------------------------------------------- | ----------------------------------------------------------------- | ------ | -------- | -| animation | 动画 className | string \| Partial> | - | | -| animationAppear | 子元素第一次挂载时是否执行动画 | boolean | true | | -| component | 包裹子元素的标签 | React.ElementType | 'div' | | -| singleMode | 是否只有单个子元素,如果有多个子元素,请设置为 false | boolean | true | | -| beforeAppear | 执行第一次挂载动画前触发的回调函数 | (node: HTMLElement) => void | - | | -| onAppear | 执行第一次挂载动画,添加 xxx-appear-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | -| afterAppear | 执行完第一次挂载动画后触发的函数 | (node: HTMLElement) => void | - | | -| beforeEnter | 执行进场动画前触发的回调函数 | (node: HTMLElement) => void | - | | -| onEnter | 执行进场动画,添加 xxx-enter-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | -| afterEnter | 执行完进场动画后触发的回调函数 | (node: HTMLElement) => void | - | | -| beforeLeave | 执行离场动画前触发的回调函数 | (node: HTMLElement) => void | - | | -| onLeave | 执行离场动画,添加 xxx-leave-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | -| afterLeave | 执行完离场动画后触发的回调函数 | (node: HTMLElement) => void | - | | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------------- | --------------------------------------------------------------- | ------------------------------------------------------------------- | ------ | -------- | +| animation | 动画 className | string \| Partial\> | - | | +| animationAppear | 子元素第一次挂载时是否执行动画 | boolean | true | | +| component | 包裹子元素的标签 | React.ElementType | 'div' | | +| singleMode | 是否只有单个子元素,如果有多个子元素,请设置为 false | boolean | true | | +| beforeAppear | 执行第一次挂载动画前触发的回调函数 | (node: HTMLElement) => void | - | | +| onAppear | 执行第一次挂载动画,添加 xxx-appear-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | +| afterAppear | 执行完第一次挂载动画后触发的函数 | (node: HTMLElement) => void | - | | +| beforeEnter | 执行进场动画前触发的回调函数 | (node: HTMLElement) => void | - | | +| onEnter | 执行进场动画,添加 xxx-enter-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | +| afterEnter | 执行完进场动画后触发的回调函数 | (node: HTMLElement) => void | - | | +| beforeLeave | 执行离场动画前触发的回调函数 | (node: HTMLElement) => void | - | | +| onLeave | 执行离场动画,添加 xxx-leave-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | +| afterLeave | 执行完离场动画后触发的回调函数 | (node: HTMLElement) => void | - | | ### Animate.Expand -| 参数 | 说明 | 类型 | 默认值 | 是否必填 | -| ----------- | -------------------------------------------------------- | ----------------------------------------------------------------- | ------ | -------- | -| animation | 动画 className | string \| Partial> | - | | -| beforeEnter | 执行进场动画前触发的回调函数 | (node: HTMLElement) => void | - | | -| onEnter | 执行进场动画,添加 xxx-enter-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | -| afterEnter | 执行完进场动画后触发的回调函数 | (node: HTMLElement) => void | - | | -| beforeLeave | 执行离场动画前触发的回调函数 | (node: HTMLElement) => void | - | | -| onLeave | 执行离场动画,添加 xxx-leave-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | -| afterLeave | 执行完离场动画后触发的回调函数 | (node: HTMLElement) => void | - | | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ----------- | -------------------------------------------------------- | ------------------------------------------------------------------- | ------ | -------- | +| animation | 动画 className | string \| Partial\> | - | | +| beforeEnter | 执行进场动画前触发的回调函数 | (node: HTMLElement) => void | - | | +| onEnter | 执行进场动画,添加 xxx-enter-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | +| afterEnter | 执行完进场动画后触发的回调函数 | (node: HTMLElement) => void | - | | +| beforeLeave | 执行离场动画前触发的回调函数 | (node: HTMLElement) => void | - | | +| onLeave | 执行离场动画,添加 xxx-leave-active 类名后触发的回调函数 | (node: HTMLElement) => void | - | | +| afterLeave | 执行完离场动画后触发的回调函数 | (node: HTMLElement) => void | - | | ### Animate.OverlayAnimate | 参数 | 说明 | 类型 | 默认值 | 是否必填 | | ------------- | ----------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | ------ | -------- | -| animation | 动画 className | string \| false \| Record<'in' \| 'out', string> | - | | +| animation | 动画 className | string \| false \| Record\<'in' \| 'out', string> | - | | | visible | 是否显示 | boolean | - | | | children | 子元素 | ReactElement | - | 是 | | timeout | 过渡的超时时间。 | \| number
\| { appear?: number \| undefined; enter?: number \| undefined; exit?: number \| undefined } | - | | diff --git a/components/animate/__tests__/index-spec.scss b/components/animate/__tests__/index-spec.scss index 42cadd4b55..dfb2c30ea7 100644 --- a/components/animate/__tests__/index-spec.scss +++ b/components/animate/__tests__/index-spec.scss @@ -53,7 +53,7 @@ } .expand-enter-active { - transition: all .3s ease-out 0.2s; + transition: all .3s ease-out .5s; } .expand-leave { diff --git a/components/animate/__tests__/index-spec.tsx b/components/animate/__tests__/index-spec.tsx index b3a3ff71b9..4d40e335bd 100644 --- a/components/animate/__tests__/index-spec.tsx +++ b/components/animate/__tests__/index-spec.tsx @@ -97,13 +97,14 @@ describe('Animate', () => { cy.get('.basic-demo').should('not.exist'); }); - it('should play expand animation', () => { + it('should play expand animation(height from 0 to auto)', () => { cy.mount(); cy.get('button').click(); cy.get('.demo-wrapper') .invoke('height') .should('satisfy', num => { // 避免不同浏览器对 .5px 处理方式的不同造成的测试失败,下同 + cy.task('log', num); return num < 24; }); cy.get('.demo-wrapper') diff --git a/components/animate/types.ts b/components/animate/types.ts index 7c2d6619ce..3ee20e378b 100644 --- a/components/animate/types.ts +++ b/components/animate/types.ts @@ -110,7 +110,17 @@ export interface AnimateProps extends React.HTMLAttributes, CommonP /** * @api Animate.Expand */ -export interface ExpandProps { +export interface ExpandProps + extends Omit< + AnimateProps, + | 'animation' + | 'beforeEnter' + | 'onEnter' + | 'afterEnter' + | 'beforeLeave' + | 'onLeave' + | 'afterLeave' + > { /** * 动画 className * @en The animation className diff --git a/components/avatar/__docs__/index.en-us.md b/components/avatar/__docs__/index.en-us.md index 29851361c5..fe9c84c80f 100644 --- a/components/avatar/__docs__/index.en-us.md +++ b/components/avatar/__docs__/index.en-us.md @@ -8,21 +8,25 @@ --- ## Develop Guide + 1.19.0+ supported ### When to Use + Avatars can be used to represent people or objects. It supports images, Icons, or letters. ## API ### Avatar -| Param | Description | Type | Default Value | -| ------- | ---------------------------------------------------------------------------- | ---------------- | -------- | -| size | size of avatar | Enum/Number | 'medium' | -| shape | shape of avatar

**option**:
'circle', 'square' | Enum | 'circle' | -| icon | the `Icon` type for an icon avatar, it can be any type of `Icon` Component or `ReactNode` | ReactNode/String | - | -| src | the address of the image for an image avatars | String | - | -| onError | error handler of image, return false to prevent default fallback behavior

**signatures**:
Function() => void | Function | - | -| alt | This attribute defines the alternative text describing the image | String | - | -| srcSet | a list of sources to use for different screen resolutions | String | - | +| Param | Description | Type | Default Value | Required | +| -------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | ------------- | -------- | +| children | Children node list | React.ReactNode | - | | +| size | The size of the avatar | 'small' \| 'medium' \| 'large' \| number | 'medium' | | +| shape | The shape of the avatar | 'circle' \| 'square' | 'circle' | | +| icon | The icon type of the icon avatar, can be set to the `type` or `ReactElement` of Icon | React.ReactElement \| string | - | | +| src | The resource address of the image avatar | string | - | | +| onError | The event of the image loading failure, returning false will close the component's default fallback behavior | () => boolean | - | | +| imgProps | The other properties of the image | Omit\<
React.ImgHTMLAttributes\,
'src' \| 'srcSet' \| 'onError' \| 'alt'
> | - | | +| alt | The alt replacement text when the image cannot be displayed | string | - | | +| srcSet | The responsive resource address of the image avatar | string | - | | diff --git a/components/avatar/__docs__/index.md b/components/avatar/__docs__/index.md index 9ffedb8648..dcc309c6fd 100644 --- a/components/avatar/__docs__/index.md +++ b/components/avatar/__docs__/index.md @@ -18,12 +18,14 @@ ### Avatar -| 参数 | 说明 | 类型 | 默认值 | -| ------- | ------------------------------------------------------------------------------- | ---------------- | -------- | -| size | 头像的大小 | Enum/Number | 'medium' | -| shape | 头像的形状

**可选值**:
'circle'(圆形)
'square'(方形) | Enum | 'circle' | -| icon | icon 类头像的图标类型,可设为 Icon 的 `type` 或 `ReactNode` | ReactNode/String | - | -| src | 图片类头像的资源地址 | String | - | -| onError | 图片加载失败的事件,返回 false 会关闭组件默认的 fallback 行为

**签名**:
Function() => void | Function | - | -| alt | 图像无法显示时的 alt 替代文本 | String | - | -| srcSet | 图片类头像响应式资源地址 | String | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| -------- | -------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- | -------- | -------- | +| children | 孩子节点列表 | React.ReactNode | - | | +| size | 头像的大小 | 'small' \| 'medium' \| 'large' \| number | 'medium' | | +| shape | 头像的形状 | 'circle' \| 'square' | 'circle' | | +| icon | icon 类头像的图标类型,可设为 Icon 的 `type` 或 `ReactElement` | React.ReactElement \| string | - | | +| src | 图片类头像的资源地址 | string | - | | +| onError | 图片加载失败的事件,返回 false 会关闭组件默认的 fallback 行为 | () => boolean | - | | +| imgProps | 图片的其他属性 | Omit\<
React.ImgHTMLAttributes\,
'src' \| 'srcSet' \| 'onError' \| 'alt'
> | - | | +| alt | 图像无法显示时的 alt 替代文本 | string | - | | +| srcSet | 图片类头像响应式资源地址 | string | - | | diff --git a/components/avatar/__tests__/index-spec.tsx b/components/avatar/__tests__/index-spec.tsx index 3f2a5b4523..60372293f2 100644 --- a/components/avatar/__tests__/index-spec.tsx +++ b/components/avatar/__tests__/index-spec.tsx @@ -3,6 +3,7 @@ import Avatar from '../index'; import Icon from '../../icon'; import '../style'; import '../../icon/style'; +import Box from '../../box'; describe('Avatar', () => { it('should render', () => { @@ -41,4 +42,19 @@ describe('Avatar', () => { cy.mount(); cy.get('.next-avatar').should('have.html', 'U'); }); + it('should render current size when avatar in box', () => { + cy.mount( + + + + ); + cy.get('.next-avatar').should('have.css', 'width', '24px'); + cy.get('.next-avatar').should('have.css', 'height', '24px'); + }); + // feature: imgProps referrerPolicy + it('should set src referrerPolicy', () => { + const link = 'https://img.alicdn.com/tfs/TB1EHhicAH0gK0jSZPiXXavapXa-904-826.png'; + cy.mount(); + cy.get('img').should('have.attr', 'referrerPolicy', 'no-referrer'); + }); }); diff --git a/components/avatar/index.tsx b/components/avatar/index.tsx index f54b1efd48..0f3439bf57 100644 --- a/components/avatar/index.tsx +++ b/components/avatar/index.tsx @@ -56,12 +56,12 @@ class Avatar extends Component { }; render() { - const { prefix, className, style, size, icon, alt, srcSet, shape, src } = this.props; + const { prefix, className, style, size, icon, alt, srcSet, shape, src, imgProps } = + this.props; const { isImgExist } = this.state; let { children } = this.props; const others = obj.pickOthers(Avatar.propTypes, this.props); - const cls = classNames( { [`${prefix}avatar`]: true, @@ -86,7 +86,13 @@ class Avatar extends Component { if (src) { if (isImgExist) { children = ( - {alt} + {alt} ); } else { children = ; @@ -99,7 +105,7 @@ class Avatar extends Component { } return ( - + {children} ); diff --git a/components/avatar/types.ts b/components/avatar/types.ts index 11444d67f3..8c4f1baf62 100644 --- a/components/avatar/types.ts +++ b/components/avatar/types.ts @@ -1,5 +1,5 @@ -import React from 'react'; -import { CommonProps } from '../util'; +import type React from 'react'; +import type { CommonProps } from '../util'; /** * @api Avatar @@ -38,6 +38,14 @@ export interface AvatarProps extends React.HTMLAttributes, CommonPr * @en The event of the image loading failure, returning false will close the component's default fallback behavior */ onError?: () => boolean; + /** + * 图片的其他属性 + * @en The other properties of the image + */ + imgProps?: Omit< + React.ImgHTMLAttributes, + 'src' | 'srcSet' | 'onError' | 'alt' + >; /** * 图像无法显示时的 alt 替代文本 * @en The alt replacement text when the image cannot be displayed diff --git a/components/badge/__docs__/index.en-us.md b/components/badge/__docs__/index.en-us.md index 521a77f616..676e14e93e 100644 --- a/components/badge/__docs__/index.en-us.md +++ b/components/badge/__docs__/index.en-us.md @@ -14,6 +14,7 @@ When we receive a new message, or our app/plugin/module should be update or upgrade. ### Accessibility + You can add class as below, so that messages will not appear on pages, but can be read by screen reader. `unread messages` @@ -21,11 +22,11 @@ You can add class as below, so that messages will not appear on pages, but can b ### Badge -| Param | Descripiton | Type | Default Value | -| ------------- | ----------------------------------------------------- | ------------- | ----- | -| children | content of Badge based on | ReactNode | - | -| count | number to display, display ${overflowCount}+ when count is greater than overflowCount, display none when count equal to 0 | Number/String | 0 | -| showZero | whether to show count when count is 0 | Boolean | false | -| content | customized node content | ReactNode | - | -| overflowCount | max number to display | Number/String | 99 | -| dot | display a red dot, not a number | Boolean | false | +| Param | Description | Type | Default Value | Required | +| ------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------- | -------- | +| children | Content of Badge based on | React.ReactNode | - | | +| count | Number to display, display overflowCount+ when count is greater than overflowCount, display none when count equal to 0 | number \| string | 0 | | +| content | Customized node content | React.ReactNode | - | | +| overflowCount | Max number to display | number \| string | 99 | | +| dot | Display a red dot, not a number | boolean | false | | +| showZero | Whether to show count when count is 0 | boolean | false | | diff --git a/components/badge/__docs__/index.md b/components/badge/__docs__/index.md index 41be832ef2..0411ae9eaa 100644 --- a/components/badge/__docs__/index.md +++ b/components/badge/__docs__/index.md @@ -23,11 +23,11 @@ ### Badge -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| ------------- | -------------------------------------------------------------------------------- | ------------- | ------ | -------- | -| children | 徽标依托的内容,一般显示在其右上方 | ReactNode | - | | -| count | 展示的数字,大于 `overflowCount` 时显示为 `${overflowCount}+`,为 `0` 时默认隐藏 | Number/String | 0 | | -| showZero | 当`count`为`0`时,是否显示 count | Boolean | false | 1.16 | -| content | 自定义徽标中的内容 | ReactNode | - | | -| overflowCount | 展示的封顶的数字 | Number/String | 99 | | -| dot | 不展示数字,只展示一个小红点 | Boolean | false | | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------------- | ------------------------------------------------------------------- | ---------------- | ------ | -------- | +| children | 徽章依托的内容 | React.ReactNode | - | | +| count | 展示的数字,大于 overflowCount 时显示为 overflowCount+,为 0 时隐藏 | number \| string | 0 | | +| content | 自定义节点内容 | React.ReactNode | - | | +| overflowCount | 展示的封顶的数字 | number \| string | 99 | | +| dot | 不展示数字,只展示一个小红点 | boolean | false | | +| showZero | 当 count 为 0 时,默认不显示,但是可以使用 showZero 修改为显示 | boolean | false | | diff --git a/components/badge/__tests__/index-spec.tsx b/components/badge/__tests__/index-spec.tsx index bb9373657a..cee67ab1fc 100644 --- a/components/badge/__tests__/index-spec.tsx +++ b/components/badge/__tests__/index-spec.tsx @@ -60,7 +60,7 @@ describe('Badge', () => { if (support.animation) { const expectTransition = () => { if (removeTransition) { - cy.get('@number').should('have.css', 'transition', 'none 0s ease 0s'); + cy.get('@number').should('have.css', 'transition', 'none'); } else { cy.get('@number').should( 'have.css', @@ -116,4 +116,37 @@ describe('Badge', () => { cy.mount(); cy.get('.next-badge-count.next-badge-scroll-number'); }); + + it('should on right when children is block', () => { + cy.mount( + +
+
+ ); + cy.get('.next-badge-count').then($el => { + $el.css({ + transition: 'none', + animation: 'none', + }); + }); + cy.get('.next-badge-count').then($el => { + const targetRect = $el[0].getBoundingClientRect(); + const badgeRect = document.querySelector('.next-badge')!.getBoundingClientRect(); + const position = { + left: Math.round(targetRect.left), + top: Math.round(targetRect.top), + }; + expect(position).to.deep.equal({ + left: Math.round(badgeRect.left + badgeRect.width - targetRect.width / 2), + top: Math.round(badgeRect.top - 4), + }); + }); + }); }); diff --git a/components/badge/main.scss b/components/badge/main.scss index cf74b31ca7..75aa0a5188 100644 --- a/components/badge/main.scss +++ b/components/badge/main.scss @@ -77,6 +77,8 @@ z-index: 10; overflow: hidden; transform-origin: left center; + transform: translateX(50%); + right: 0; } &-scroll-number-only { diff --git a/components/balloon/__docs__/adaptor/index.jsx b/components/balloon/__docs__/adaptor/index.jsx deleted file mode 100644 index 2d806f4146..0000000000 --- a/components/balloon/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,87 +0,0 @@ -import React from 'react'; -import { Balloon } from '@alifd/next'; -import { Types } from '@alifd/adaptor-helper'; - -const ALIGN_LIST = [ - { label: 'Top', value: 'b' }, // (上) - { label: 'Right', value: 'l' }, // (右) - { label: 'Bottom', value: 't' }, // (下) - { label: 'Left', value: 'r' }, // (左) - { label: 'Top Left', value: 'br' }, // (上左) - { label: 'Top Right', value: 'bl' }, // (上右) - { label: 'Bottom Left', value: 'tr' }, // (下左) - { label: 'Bottom Right', value: 'tl' }, // (下右) - { label: 'Left Top', value: 'rt' }, // (左上) - { label: 'Left Bottom', value: 'rb' }, // (左下) - { label: 'Right Top', value: 'lt' }, // (右上) - { label: 'Right Bottom', value: 'lb' }, // (右下 及其 两两组合) -]; - - -export default { - name: 'Balloon', - shape: [{ - label: 'Balloon', - value: 'balloon' - }, { - label: 'Tooltip', - value: 'tooltip' - }], - editor: (shape) => { - return { - props: [ - shape === 'balloon' && { - name: 'level', - type: Types.enum, - options: ['normal', 'primary'], - default: 'normal', - }, - { - name: 'direction', - label: 'Align', - type: Types.enum, - options: ALIGN_LIST, - default: 'b', - }, - shape === 'balloon' ? - { - name: 'closable', - type: Types.bool, - default: true - } : - null - ].filter(v => !!v), - data: { - default: `${shape.substring(0, 1).toUpperCase() + shape.substring(1)} content replace holder.` - } - }; - }, - adaptor: ({ shape, level, direction, closable, data, style, ...others }) => { - return ( - - {data} - - ); - }, - content: (shape) => ({ - options: [ - { - name: 'direction', - options: ALIGN_LIST, - default: 'b' - }, - shape === 'balloon' && { - name: 'closable', - options: ['yes', 'no'], - default: 'yes' - } - ].filter(v => !!v), - transform: (props, { direction, closable }) => { - return { - ...props, - direction, - closable: closable === 'yes', - } - } - }) -}; diff --git a/components/balloon/__docs__/adaptor/index.tsx b/components/balloon/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..51e8717d38 --- /dev/null +++ b/components/balloon/__docs__/adaptor/index.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import { Balloon } from '@alifd/next'; +import { Types } from '@alifd/adaptor-helper'; +import type { AlignType } from '../../types'; + +const ALIGN_LIST = [ + { label: 'Top', value: 'b' }, // (上) + { label: 'Right', value: 'l' }, // (右) + { label: 'Bottom', value: 't' }, // (下) + { label: 'Left', value: 'r' }, // (左) + { label: 'Top Left', value: 'br' }, // (上左) + { label: 'Top Right', value: 'bl' }, // (上右) + { label: 'Bottom Left', value: 'tr' }, // (下左) + { label: 'Bottom Right', value: 'tl' }, // (下右) + { label: 'Left Top', value: 'rt' }, // (左上) + { label: 'Left Bottom', value: 'rb' }, // (左下) + { label: 'Right Top', value: 'lt' }, // (右上) + { label: 'Right Bottom', value: 'lb' }, // (右下 及其 两两组合) +]; + +export default { + name: 'Balloon', + shape: [ + { + label: 'Balloon', + value: 'balloon', + }, + { + label: 'Tooltip', + value: 'tooltip', + }, + ], + editor: (shape: string) => { + return { + props: [ + shape === 'balloon' && { + name: 'level', + type: Types.enum, + options: ['normal', 'primary'], + default: 'normal', + }, + { + name: 'direction', + label: 'Align', + type: Types.enum, + options: ALIGN_LIST, + default: 'b', + }, + shape === 'balloon' + ? { + name: 'closable', + type: Types.bool, + default: true, + } + : null, + ].filter(v => !!v), + data: { + default: `${ + shape.substring(0, 1).toUpperCase() + shape.substring(1) + } content replace holder.`, + }, + }; + }, + adaptor: ({ + shape, + level, + direction, + closable, + data, + style, + ...others + }: { + shape: string; + level: string; + direction: AlignType; + closable: boolean; + data: string; + style: React.CSSProperties; + }) => { + return ( + + {data} + + ); + }, + content: (shape: string) => ({ + options: [ + { + name: 'direction', + options: ALIGN_LIST, + default: 'b', + }, + shape === 'balloon' && { + name: 'closable', + options: ['yes', 'no'], + default: 'yes', + }, + ].filter(v => !!v), + transform: ( + props: any, + { direction, closable }: { direction: AlignType; closable: string } + ) => { + return { + ...props, + direction, + closable: closable === 'yes', + }; + }, + }), +}; diff --git a/components/balloon/__docs__/demo/accessibility/index.tsx b/components/balloon/__docs__/demo/accessibility/index.tsx index 4da15525fe..6da9402373 100644 --- a/components/balloon/__docs__/demo/accessibility/index.tsx +++ b/components/balloon/__docs__/demo/accessibility/index.tsx @@ -2,7 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Balloon, Input } from '@alifd/next'; -import moment from 'moment'; const { Tooltip } = Balloon; const innerButton = ; @@ -15,7 +14,7 @@ const App = () => ( id="inner-a11y-balloon-1" autoFocus trigger={} - popupContainer={trigger => trigger.parentNode} + popupContainer={(trigger: HTMLElement) => trigger.parentNode} triggerType="click" > please input your age: @@ -39,7 +38,7 @@ const App = () => ( id="inner-a11y-balloon" autoFocus trigger={} - popupContainer={trigger => trigger.parentNode} + popupContainer={(trigger: HTMLElement) => trigger.parentNode} triggerType="click" > please input your age: diff --git a/components/balloon/__docs__/demo/arrow-point-to-center/index.tsx b/components/balloon/__docs__/demo/arrow-point-to-center/index.tsx index 8cb2c056eb..f7ccfb3e4c 100644 --- a/components/balloon/__docs__/demo/arrow-point-to-center/index.tsx +++ b/components/balloon/__docs__/demo/arrow-point-to-center/index.tsx @@ -14,11 +14,6 @@ const pointCenterTrigger = ( Arrow Point To Center / 箭头指向中心 ); -const primary = ( - -); const Demo = () => (
diff --git a/components/balloon/__docs__/demo/control/index.tsx b/components/balloon/__docs__/demo/control/index.tsx index 91fec62845..e30a4de3be 100644 --- a/components/balloon/__docs__/demo/control/index.tsx +++ b/components/balloon/__docs__/demo/control/index.tsx @@ -2,8 +2,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Balloon } from '@alifd/next'; -class App extends React.Component { - constructor(props) { +interface AppProps {} +class App extends React.Component { + constructor(props: AppProps) { super(props); this.state = { visible: false, @@ -19,7 +20,7 @@ class App extends React.Component { // onVisibleChange callback will be triggered when visible changes. // For example, for click type, it'll be triggered when clicking the button and later the other areas; // for hover type, it'll be triggered when mouse enter and mouse leave - handleVisibleChange(visible) { + handleVisibleChange(visible: boolean) { this.setState({ visible }); } diff --git a/components/balloon/__docs__/demo/onCloseClick/index.tsx b/components/balloon/__docs__/demo/onCloseClick/index.tsx index 9c2aecdd57..b29fe1079a 100644 --- a/components/balloon/__docs__/demo/onCloseClick/index.tsx +++ b/components/balloon/__docs__/demo/onCloseClick/index.tsx @@ -2,8 +2,9 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Balloon } from '@alifd/next'; -class App extends React.Component { - constructor(props) { +interface AppProps {} +class App extends React.Component { + constructor(props: AppProps) { super(props); this.state = { visible: false, diff --git a/components/balloon/__docs__/demo/tooltip/index.tsx b/components/balloon/__docs__/demo/tooltip/index.tsx index b104bf94d0..bbfd45f23c 100644 --- a/components/balloon/__docs__/demo/tooltip/index.tsx +++ b/components/balloon/__docs__/demo/tooltip/index.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { type ReactNode } from 'react'; import ReactDOM from 'react-dom'; -import { Button, Balloon, Table } from '@alifd/next'; +import { Balloon, Table } from '@alifd/next'; const Tooltip = Balloon.Tooltip; @@ -16,7 +16,7 @@ const dataSource = [ id: 100360941, }, ]; -const render = (value, index, record) => { +const render = (value: ReactNode) => { const intro = (
{value} diff --git a/components/balloon/__docs__/index.en-us.md b/components/balloon/__docs__/index.en-us.md index e3c5cb98a0..d493507a29 100644 --- a/components/balloon/__docs__/index.en-us.md +++ b/components/balloon/__docs__/index.en-us.md @@ -20,65 +20,115 @@ ## API -### Balloon - -| Param | Description | Type | Default Value | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------- | ---------------------------------------- | -| type | type of style

**option**:
'normal', 'primary' | Enum | 'normal' | -| children | content of popup layer | any | - | -| title | title | ReactNode | - | 1.23 | -| visible | visible state of popup | Boolean | - | -| defaultVisible | default visible state of popup | Boolean | false | -| onVisibleChange | callback when visible state changes

**signature**:
Function(visible: Boolean) => void
**parameter**:
_visible_: {Boolean} whether to show the popup | Function | func.noop | -| alignEdge | whether align to the edge | Boolean | false | -| closable | whether to show the close button | Boolean | true | -| align | position of popup relative to the trigger

**option**:
't'(top)
'r'(right)
'b'(bottom)
'l'(left)
'tl'(top left)
'tr'(top right)
'bl'(bottom left)
'br'(bottom right)
'lt'(left top)
'lb'(left bottom)
'rt'(right top)
'rb'(right bottom) or their combinations | Enum | 'b' | -| offset | extra adjustment for trigger element. e.g. [hoz, ver] means move to right ${hoz}px (to left in RTL mode), to bottom ${ver}px | Array | [0, 0] | -| trigger | trigger of the popup | any | <span></span> | -| triggerType | how to trigger the popup.

**type unit**:
'hover'
'click'
e.g.['hover', 'click'] 'click' | String/Array | 'hover' | -| onClose | callback triggered when visible becomes false

**signature**:
Function() => void | Function | func.noop | -| needAdjust | whether to adjust the position automatically | Boolean | false | -| delay | how long should the popup be delayed after triggered in milliseconds | Number | - | -| afterClose | callback triggered when the popup is closed or the animation ends

**signature**:
Function() => void | Function | func.noop | -| shouldUpdatePosition | whether to update the position of popup after the content changes | Boolean | - | -| autoFocus | whether to focus on the first element of popup on appearing | Boolean | false | -| safeNode | When triggetType is 'click', the popup will be closed if any area other than itself is clicked. safeNode is used to define the node which doesn't trigger the close action. It can be either dom or dom id | String | undefined | -| safeId | id of the safeNode, and should be used together with safeNode | String | null | -| animation | when should the animation be played | Object/Boolean | { in: 'zoomIn', out: 'zoomOut' } | -| cache | whether to remove the popup when it's closed | Boolean | false | -| popupContainer | popupContainer of the popup, being either dom id or a function to return the dom | String/Function | - | -| popupStyle | custom style of popup | Object | {} | -| popupClassName | custom className of popup | String | '' | -| popupProps | props of popup | Object | {} | -| followTrigger | follow Trigger or not | Boolean | - | -| id | id of popup. only when you set value, balloon will support accessibility | String | - | - -### Balloon.Tooltip - -| Param | Description | Type | Default Value | -| -------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------- | ------------------- | -| children | content of tooltip | any | - | -| align | position of popup relative to the trigger

**option**:
't'(top)
'r'(right)
'b'(bottom)
'l'(left)
'tl'(top left)
'tr'(top right)
'bl'(bottom left)
'br'(bottom right)
'lt'(left top)
'lb'(left bottom)
'rt'(right top)
'rb'(right bottom) or their combinations | Enum | 'b' | -| trigger | trigger of the tooltip | any | <span></span> | -| triggerType | how to trigger the tooltip.

**type unit**:
'hover'
'click'
e.g.['hover', 'click'] 'click'. `` for complex usage | String/Array | 'hover' | -| popupStyle | custom style of popup | Object | - | -| popupClassName | custom className of popup | String | - | -| popupProps | props of popup | Object | - | -| pure | pure render or not | Boolean | - | -| popupContainer | popupContainer of the popup, being either dom id or a function to return the dom | String/Function | - | -| id | id of popup. only when you set value, balloon will support accessibility | String | - | -| delay | If you want the content of Tooltip to be clickable, set 100 for example | Number | 0 | +### Balloon V2 + +| Param | Description | Type | Default Value | Required | Supported Version | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | -------- | ----------------- | +| v2 | Enable v2 | true | - | | 1.25 | +| children | Content of popup | ReactNode | - | | - | +| type | Style type | 'normal' \| 'primary' | 'normal' | | 1.23 | +| title | Title | ReactNode | - | | 1.23 | +| visible | Popup current display status | boolean | - | | - | +| defaultVisible | Popup default display status | boolean | false | | - | +| onVisibleChange | Popup display and hide event

**signature**:
**params**:
_visible_: Wether the popup is hidden or displayed
_type_: Source of trigger popup display or hide, closeClick means triggered by the close button; fromTrigger means triggered by the trigger click; docClick means triggered by the document click | (visible: boolean, type: string) => void | - | | - | +| arrowPointToCenter | Whether the arrow points to the center of the target element | boolean | false | | 1.25 | +| placementOffset | Popup offset | number | - | | - | +| closable | Whether to display close button | boolean | true | | - | +| align | Position of popup | AlignType | 'b' | | - | +| offset | Tuning of popup relative to trigger, receive an array [hoz, ver], indicating the offset of the popup on left / top, e.g. [100, 100] means to the right (in RTL mode, it is to the left) and downward offset 100px | Array\ | [0, 0] | | - | +| trigger | Trigger element | ReactElement \| string | | | - | +| triggerType | Trigger behavior, mouse hover, mouse click ('hover','click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | - | +| onClose | Any event triggered when visible is false | () => void | - | | - | +| autoAdjust | Whether to perform automatic position adjustment, default automatic opening | boolean | - | | 1.25 | +| delay | Popup delay display | number | - | | - | +| afterClose | Popup close event | () => void | - | | - | +| autoFocus | Whether to automatically focus to the internal first element | boolean | true | | - | +| safeNode | Safe node: for the popup with triggerType set to click, the popup will be closed when clicking on other areas other than the popup | string \| ReactNode | - | | - | +| safeId | Used to specify the id of the safeNode node, and combined with safeNode | string | null | | - | +| animation | Configure the playback method of the animation, the format is \{ in: '', out: '' \}, commonly used animation class please see the documentation of the Animate component

**signature**:
**params**:
_in_: in
_out_: out | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} | | - | +| cache | Whether to delete the dom node of the popup when it is closed | boolean | false | | - | +| popupContainer | Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node | PopupProps['container'] | - | | - | +| popupStyle | Popup style | CSSProperties | - | | - | +| popupClassName | Popup className | string | - | | - | +| popupProps | Popup props | ComponentPropsWithRef\ | - | | - | +| followTrigger | Follow scrolling | boolean | - | | - | +| id | Popup id, if passed value will support accessibility | string | - | | - | + +### Balloon V1 + +| Param | Description | Type | Default Value | Required | Supported Version | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | -------- | ----------------- | +| v2 | Enable v2 | false \| undefined | - | | 1.25 | +| children | Content of popup | ReactNode | - | | - | +| title | Title | ReactNode | - | | 1.23 | +| type | Style type | 'normal' \| 'primary' | 'normal' | | 1.23 | +| visible | Popup current display status | boolean | - | | - | +| defaultVisible | Popup default display status | boolean | false | | - | +| onVisibleChange | Popup display and hide event

**signature**:
**params**:
_visible_: Wether the popup is hidden or displayed
_type_: Source of trigger popup display or hide, closeClick means triggered by the close button; fromTrigger means triggered by the trigger click; docClick means triggered by the document click | (visible: boolean, type: string) => void | - | | - | +| closable | Whether to display close button | boolean | true | | - | +| align | Position of popup | AlignType | 'b' | | - | +| offset | Tuning of popup relative to trigger, receive an array [hoz, ver], indicating the offset of the popup on left / top, e.g. [100, 100] means to the right (in RTL mode, it is to the left) and downward offset 100px | Array\ | [0, 0] | | - | +| trigger | Trigger element | ReactElement \| string | | | - | +| triggerType | Trigger behavior, mouse hover, mouse click ('hover','click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | - | +| onClose | Any event triggered when visible is false | () => void | - | | - | +| delay | Popup delay display | number | - | | - | +| afterClose | Popup close event | () => void | - | | - | +| autoFocus | Whether to automatically focus to the internal first element | boolean | true | | - | +| safeNode | Safe node: for the popup with triggerType set to click, the popup will be closed when clicking on other areas other than the popup | string \| ReactNode | - | | - | +| safeId | Used to specify the id of the safeNode node, and combined with safeNode | string | null | | - | +| animation | Configure the playback method of the animation, the format is \{ in: '', out: '' \}, commonly used animation class please see the documentation of the Animate component

**signature**:
**params**:
_in_: in
_out_: out | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} | | - | +| cache | Whether to delete the dom node of the popup when it is closed | boolean | false | | - | +| popupContainer | Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node | PopupProps['container'] | - | | - | +| popupStyle | Popup style | CSSProperties | - | | - | +| popupClassName | Popup className | string | - | | - | +| popupProps | Popup props | ComponentPropsWithRef\ | - | | - | +| followTrigger | Follow scrolling | boolean | - | | - | +| id | Popup id, if passed value will support accessibility | string | - | | - | + +### Balloon.Tooltip V2 + +| Param | Description | Type | Default Value | Required | +| ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------- | -------- | +| v2 | Enable v2 | true | - | | +| children | Content of tooltip | ReactNode | - | | +| align | Position of popup | AlignType | 'b' | | +| trigger | Trigger element | ReactElement \| string | | | +| triggerType | Trigger behavior, mouse hover, mouse click ('hover', 'click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use the Balloon component with triggerType set to click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | +| popupStyle | Popup style | CSSProperties | - | | +| popupClassName | Popup className | string | - | | +| popupProps | Popup props | ComponentPropsWithRef\ | - | | +| pure | Whether to pure render | boolean | - | | +| popupContainer | Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node | PopupProps['container'] | - | | +| followTrigger | Whether to follow scrolling | boolean | - | | +| id | Popup id, if passed value will support accessibility | string | - | | +| delay | If needed, set this parameter to allow the Tooltip content to be clicked, e.g. 100px | number | 50 | | +| arrowPointToCenter | Whether the arrow points to the center of the target element | boolean | false | | + +### Balloon.Tooltip V1 + +| Param | Description | Type | Default Value | Required | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------------- | -------- | +| v2 | Enable v2 | false \| undefined | - | | +| children | Content of tooltip | ReactNode | - | | +| align | Position of popup | AlignType | 'b' | | +| trigger | Trigger element | ReactElement \| string | | | +| triggerType | Trigger behavior, mouse hover, mouse click ('hover', 'click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use the Balloon component with triggerType set to click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | +| popupStyle | Popup style | CSSProperties | - | | +| popupClassName | Popup className | string | - | | +| popupProps | Popup props | ComponentPropsWithRef\ | - | | +| pure | Whether to pure render | boolean | - | | +| popupContainer | Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node | PopupProps['container'] | - | | +| followTrigger | Whether to follow scrolling | boolean | - | | +| id | Popup id, if passed value will support accessibility | string | - | | +| delay | If needed, set this parameter to allow the Tooltip content to be clicked, e.g. 100px | number | 50 | | ## Known Issues - For disabled elements, onMouseLeave can't be triggered in chrome, due to chrome's bug and can't be worked around at present. - ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| SPACE | When `triggerType=‘click’`, click will popup a prompt | -| Enter | When `triggerType=‘click’`, click will popup a prompt | - - +| KeyBoard | Descripiton | +| :------- | :---------------------------------------------------- | +| SPACE | When `triggerType=‘click’`, click will popup a prompt | +| Enter | When `triggerType=‘click’`, click will popup a prompt | diff --git a/components/balloon/__docs__/index.md b/components/balloon/__docs__/index.md index f67f3c6dae..25dc9b65fd 100644 --- a/components/balloon/__docs__/index.md +++ b/components/balloon/__docs__/index.md @@ -11,8 +11,8 @@ ## 何时使用 -- 当用户与被说明对象(文字,图片,输入框等)发生交互行为的action开始时, 即刻跟随动作出现一种辅助或帮助的提示信息。 -- 其中Balloon.Tooltip是简化版本,主要用于hover时显示简单文案 +- 当用户与被说明对象(文字,图片,输入框等)发生交互行为的 action 开始时,即刻跟随动作出现一种辅助或帮助的提示信息。 +- 其中 Balloon.Tooltip 是简化版本,主要用于 hover 时显示简单文案 ### `v2` 版本更新指示 @@ -23,67 +23,116 @@ ## 如何使用 -- 对于trigger是自定义的React Component的情况,自定义的React Component 需要透传onMouseEnter/onMouseLeave/onClick 事件。 -- 若要使用无障碍的气泡提示,请传入id。推荐简单提示使用``、复杂交互使用`` 。 triggerType="focus"作为辅助状态用于组件内部,请用户不要直接使用此值。 +- 对于 trigger 是自定义的 React Component 的情况,自定义的 React Component 需要透传 onMouseEnter/onMouseLeave/onClick 事件。 +- 若要使用无障碍的气泡提示,请传入 id。推荐简单提示使用``、复杂交互使用`` 。 triggerType="focus"作为辅助状态用于组件内部,请用户不要直接使用此值。 ## API -### Balloon - -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | -------------------------------------------------------------- | ---- | -| children | 浮层的内容 | any | - | | -| type | 样式类型

**可选值**:
'normal', 'primary' | Enum | 'normal' | | -| title | 标题 | ReactNode | - | 1.23 | -| visible | 弹层当前显示的状态 | Boolean | - | | -| defaultVisible | 弹层默认显示的状态 | Boolean | false | | -| onVisibleChange | 弹层在显示和隐藏触发的事件

**签名**:
Function(visible: Boolean, type: String) => void
**参数**:
_visible_: {Boolean} 弹层是否隐藏和显示
_type_: {String} 触发弹层显示或隐藏的来源, closeClick 表示由自带的关闭按钮触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 | Function | func.noop | | -| v2 | 开启 v2 版本 | Boolean | - | 1.25 | -| arrowPointToCenter | [v2] 箭头是否指向目标元素的中心 | Boolean | false | 1.25 | -| placementOffset | [v2] 弹层偏离触发元素的像素值 | Number | - | | -| closable | 是否显示关闭按钮 | Boolean | true | | -| align | 弹出层位置

**可选值**:
't'(上)
'r'(右)
'b'(下)
'l'(左)
'tl'(上左)
'tr'(上右)
'bl'(下左)
'br'(下右)
'lt'(左上)
'lb'(左下)
'rt'(右上)
'rb'(右下) | Enum | 'b' | | -| offset | 弹层相对于trigger的定位的微调, 接收数组[hoz, ver], 表示弹层在 left / top 上的增量
e.g. [100, 100] 表示往右(RTL 模式下是往左) 、下分布偏移100px | Array | [0, 0] | | -| trigger | 触发元素 | any | <span /> | | -| triggerType | 触发行为
鼠标悬浮, 鼠标点击('hover','click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用click | String/Array | 'hover' | | -| onClose | 任何visible为false时会触发的事件

**签名**:
Function() => void | Function | func.noop | | -| autoAdjust | [v2] 是否进行自动位置调整,默认自动开启。 | Boolean | - | 1.25 | -| delay | 弹层在触发以后的延时显示, 单位毫秒 ms | Number | - | | -| afterClose | 浮层关闭后触发的事件, 如果有动画,则在动画结束后触发

**签名**:
Function() => void | Function | func.noop | | -| autoFocus | 弹层出现后是否自动focus到内部第一个元素 | Boolean | true | | -| safeNode | 安全节点:对于triggetType为click的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode用于添加不触发关闭的节点, 值可以是dom节点的id或者是节点的dom对象 | String | undefined | | -| safeId | 用来指定safeNode节点的id,和safeNode配合使用 | String | null | | -| animation | 配置动画的播放方式,格式是{in: '', out: ''}, 常用的动画class请查看Animate组件文档 | Object/Boolean | { in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', } | | -| cache | 弹层的dom节点关闭时是否删除 | Boolean | false | | -| popupContainer | 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 | any | - | | -| popupStyle | 弹层组件style,透传给Popup | Object | {} | | -| popupClassName | 弹层组件className,透传给Popup | String | '' | | -| popupProps | 弹层组件属性,透传给Popup | Object | {} | | -| followTrigger | 是否跟随滚动 | Boolean | - | | -| id | 弹层id, 传入值才会支持无障碍 | String | - | | - -### Balloon.Tooltip - -| 参数 | 说明 | 类型 | 默认值 | -| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------- | -| children | tooltip的内容 | any | - | -| align | 弹出层位置

**可选值**:
't'(上)
'r'(右)
'b'(下)
'l'(左)
'tl'(上左)
'tr'(上右)
'bl'(下左)
'br'(下右)
'lt'(左上)
'lb'(左下)
'rt'(右上)
'rb'(右下) | Enum | 'b' | -| trigger | 触发元素 | any | <span /> | -| triggerType | 触发行为
鼠标悬浮, 鼠标点击('hover', 'click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用triggerType为click的Balloon组件 | String/Array | 'hover' | -| popupStyle | 弹层组件style,透传给Popup | Object | - | -| popupClassName | 弹层组件className,透传给Popup | String | - | -| popupProps | 弹层组件属性,透传给Popup | Object | - | -| pure | 是否pure render | Boolean | - | -| popupContainer | 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 | any | - | -| followTrigger | 是否跟随滚动 | Boolean | - | -| id | 弹层id, 传入值才会支持无障碍 | String | - | -| delay | 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100 | Number | 50 | -| v2 | 开启 v2 版本 | Boolean | - | -| arrowPointToCenter | [v2] 箭头是否指向目标元素的中心 | Boolean | false | +### Balloon V2 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | -------- | -------- | +| v2 | 开启 v2 版本 | true | - | | 1.25 | +| children | 浮层的内容 | ReactNode | - | | - | +| type | 样式类型 | 'normal' \| 'primary' | 'normal' | | 1.23 | +| title | 标题 | ReactNode | - | | 1.23 | +| visible | 弹层当前显示的状态 | boolean | - | | - | +| defaultVisible | 弹层默认显示的状态 | boolean | false | | - | +| onVisibleChange | 弹层在显示和隐藏触发的事件

**签名**:
**参数**:
_visible_: 弹层是否隐藏和显示
_type_: 触发弹层显示或隐藏的来源,closeClick 表示由自带的关闭按钮触发;fromTrigger 表示由 trigger 的点击触发;docClick 表示由 document 的点击触发 | (visible: boolean, type: string) => void | - | | - | +| arrowPointToCenter | [v2] 箭头是否指向目标元素的中心 | boolean | false | | 1.25 | +| placementOffset | [v2] 弹层偏离触发元素的像素值 | number | - | | - | +| closable | 是否显示关闭按钮 | boolean | true | | - | +| align | 弹出层位置 | AlignType | 'b' | | - | +| offset | 弹层相对于 trigger 的定位的微调,接收数组 [hoz, ver], 表示弹层在 left / top 上的增量,e.g. [100, 100] 表示往右 (RTL 模式下是往左) 、下分布偏移 100px | Array\ | [0, 0] | | - | +| trigger | 触发元素 | ReactElement \| string | | | - | +| triggerType | 触发行为,鼠标悬浮,鼠标点击 ('hover','click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用 click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | - | +| onClose | 任何 visible 为 false 时会触发的事件 | () => void | - | | - | +| autoAdjust | [v2] 是否进行自动位置调整,默认自动开启 | boolean | - | | 1.25 | +| delay | 弹层在触发以后的延时显示,单位毫秒 ms | number | - | | - | +| afterClose | 浮层关闭后触发的事件,如果有动画,则在动画结束后触发 | () => void | - | | - | +| autoFocus | 弹层出现后是否自动 focus 到内部第一个元素 | boolean | true | | - | +| safeNode | 安全节点:对于 triggetType 为 click 的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode 用于添加不触发关闭的节点,值可以是 dom 节点的 id 或者是节点的 dom 对象 | string \| ReactNode | - | | - | +| safeId | 用来指定 safeNode 节点的 id,和 safeNode 配合使用 | string | null | | - | +| animation | 配置动画的播放方式,格式是 \{ in: '', out: '' \},常用的动画 class 请查看 Animate 组件文档

**签名**:
**参数**:
_in_: 进场动画
_out_: 出场动画 | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} | | - | +| cache | 弹层的 dom 节点关闭时是否删除 | boolean | false | | - | +| popupContainer | 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 | PopupProps['container'] | - | | - | +| popupStyle | 弹层组件 style,透传给 Popup | CSSProperties | - | | - | +| popupClassName | 弹层组件 className,透传给 Popup | string | - | | - | +| popupProps | 弹层组件属性,透传给 Popup | ComponentPropsWithRef\ | - | | - | +| followTrigger | 跟随滚动 | boolean | - | | - | +| id | 弹层 id, 传入值才会支持无障碍 | string | - | | - | + +### Balloon V1 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------- | -------- | -------- | +| v2 | 开启 v2 版本 | false \| undefined | - | | 1.25 | +| children | 浮层的内容 | ReactNode | - | | - | +| title | 标题 | ReactNode | - | | 1.23 | +| type | 样式类型 | 'normal' \| 'primary' | 'normal' | | 1.23 | +| visible | 弹层当前显示的状态 | boolean | - | | - | +| defaultVisible | 弹层默认显示的状态 | boolean | false | | - | +| onVisibleChange | 弹层在显示和隐藏触发的事件

**签名**:
**参数**:
_visible_: 弹层是否隐藏和显示
_type_: 触发弹层显示或隐藏的来源,closeClick 表示由自带的关闭按钮触发;fromTrigger 表示由 trigger 的点击触发;docClick 表示由 document 的点击触发 | (visible: boolean, type: string) => void | - | | - | +| closable | 是否显示关闭按钮 | boolean | true | | - | +| align | 弹出层位置 | AlignType | 'b' | | - | +| offset | 弹层相对于 trigger 的定位的微调,接收数组 [hoz, ver], 表示弹层在 left / top 上的增量,e.g. [100, 100] 表示往右 (RTL 模式下是往左) 、下分布偏移 100px | Array\ | [0, 0] | | - | +| trigger | 触发元素 | ReactElement \| string | | | - | +| triggerType | 触发行为,鼠标悬浮,鼠标点击 ('hover','click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用 click | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | - | +| onClose | 任何 visible 为 false 时会触发的事件 | () => void | - | | - | +| delay | 弹层在触发以后的延时显示,单位毫秒 ms | number | - | | - | +| afterClose | 浮层关闭后触发的事件,如果有动画,则在动画结束后触发 | () => void | - | | - | +| autoFocus | 弹层出现后是否自动 focus 到内部第一个元素 | boolean | true | | - | +| safeNode | 安全节点:对于 triggetType 为 click 的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode 用于添加不触发关闭的节点,值可以是 dom 节点的 id 或者是节点的 dom 对象 | string \| ReactNode | - | | - | +| safeId | 用来指定 safeNode 节点的 id,和 safeNode 配合使用 | string | null | | - | +| animation | 配置动画的播放方式,格式是 \{ in: '', out: '' \},常用的动画 class 请查看 Animate 组件文档

**签名**:
**参数**:
_in_: 进场动画
_out_: 出场动画 | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} | | - | +| cache | 弹层的 dom 节点关闭时是否删除 | boolean | false | | - | +| popupContainer | 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 | PopupProps['container'] | - | | - | +| popupStyle | 弹层组件 style,透传给 Popup | CSSProperties | - | | - | +| popupClassName | 弹层组件 className,透传给 Popup | string | - | | - | +| popupProps | 弹层组件属性,透传给 Popup | ComponentPropsWithRef\ | - | | - | +| followTrigger | 跟随滚动 | boolean | - | | - | +| id | 弹层 id, 传入值才会支持无障碍 | string | - | | - | + +### Balloon.Tooltip V2 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------- | -------- | +| v2 | 开启 v2 | true | - | | +| children | tooltip 的内容 | ReactNode | - | | +| align | 弹出层位置 | AlignType | 'b' | | +| trigger | 触发元素 | ReactElement \| string | | | +| triggerType | 触发行为,鼠标悬浮,鼠标点击 ('hover', 'click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用 triggerType 为 click 的 Balloon 组件 | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | +| popupStyle | 弹层组件 style,透传给 Popup | CSSProperties | - | | +| popupClassName | 弹层组件 className,透传给 Popup | string | - | | +| popupProps | 弹层组件属性,透传给 Popup | ComponentPropsWithRef\ | - | | +| pure | 是否 pure render | boolean | - | | +| popupContainer | 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 | PopupProps['container'] | - | | +| followTrigger | 是否跟随滚动 | boolean | - | | +| id | 弹层 id, 传入值才会支持无障碍 | string | - | | +| delay | 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100px | number | 50 | | +| arrowPointToCenter | [v2] 箭头是否指向目标元素的中心 | boolean | false | | + +### Balloon.Tooltip V1 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | ------- | -------- | +| v2 | 开启 v2 | false \| undefined | - | | +| children | tooltip 的内容 | ReactNode | - | | +| align | 弹出层位置 | AlignType | 'b' | | +| trigger | 触发元素 | ReactElement \| string | | | +| triggerType | 触发行为,鼠标悬浮,鼠标点击 ('hover', 'click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用 triggerType 为 click 的 Balloon 组件 | 'hover' \| 'click' \| 'focus' \| ('hover' \| 'click' \| 'focus')[] | 'hover' | | +| popupStyle | 弹层组件 style,透传给 Popup | CSSProperties | - | | +| popupClassName | 弹层组件 className,透传给 Popup | string | - | | +| popupProps | 弹层组件属性,透传给 Popup | ComponentPropsWithRef\ | - | | +| pure | 是否 pure render | boolean | - | | +| popupContainer | 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 | PopupProps['container'] | - | | +| followTrigger | 是否跟随滚动 | boolean | - | | +| id | 弹层 id, 传入值才会支持无障碍 | string | - | | +| delay | 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100px | number | 50 | | ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---- | :------------------------------ | +| 按键 | 说明 | +| :---- | :---------------------------------------- | | SPACE | 当`triggerType=‘click’`时,点击会弹出提示 | | Enter | 当`triggerType=‘click’`时,点击会弹出提示 | diff --git a/components/balloon/__docs__/theme/index.jsx b/components/balloon/__docs__/theme/index.jsx deleted file mode 100644 index 185ba22a18..0000000000 --- a/components/balloon/__docs__/theme/index.jsx +++ /dev/null @@ -1,277 +0,0 @@ -import '../../../demo-helper/style'; -import {Demo, DemoGroup, initDemo} from '../../../demo-helper'; -import Balloon from '../../index'; -import '../../style'; -import '../../../icon/style'; - -const i18nMap = { - 'zh-cn': { - title: '气泡弹层', - tooltipContent: '提示浮层内可替换内容.', - balloonContent: '气泡浮层内可替换内容', - align: { - label: '箭头方向', - value: 'b', - enum: [ - {label: '上', value: 'b'}, - {label: '下', value: 't'}, - {label: '左', value: 'r'}, - {label: '右', value: 'l'}, - {label: '上左', value: 'br'}, - {label: '上右', value: 'bl'}, - {label: '下左', value: 'tr'}, - {label: '下右', value: 'tl'}, - {label: '左上', value: 'rb'}, - {label: '左下', value: 'rt'}, - {label: '右上', value: 'lb'}, - {label: '右下', value: 'lt'}, - ] - }, - closable: { - label: '关闭按钮', - value: 'true', - enum: [ - { - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - } - ] - } - }, - 'en-us': { - title: 'balloon', - tooltipContent: 'Tool tip content replace holder.', - balloonContent: 'Balloon content replace holder.', - align: { - label: 'direction', - value: 'b', - enum: [ - {label: 'top', value: 'b'}, - {label: 'bottom', value: 't'}, - {label: 'left', value: 'r'}, - {label: 'right', value: 'l'}, - {label: 'top left', value: 'br'}, - {label: 'top right', value: 'bl'}, - {label: 'bottom left', value: 'tr'}, - {label: 'bottom right', value: 'tl'}, - {label: 'left top', value: 'rb'}, - {label: 'left bottom', value: 'rt'}, - {label: 'right top', value: 'lb'}, - {label: 'right bottom', value: 'lt'}, - ] - }, - closable: { - label: 'closable', - value: 'true', - enum: [ - { - label: 'yes', - value: 'true' - }, { - label: 'no', - value: 'false' - } - ] - } - } -}; - -class BalloonDemo extends React.Component { - constructor(props) { - super(props); - this.state = { - demoFunction: { - align: { - label: '箭头方向', - value: 'b', - enum: [ - {label: '上', value: 'b'}, - {label: '下', value: 't'}, - {label: '左', value: 'r'}, - {label: '右', value: 'l'}, - {label: '上左', value: 'br'}, - {label: '上右', value: 'bl'}, - {label: '下左', value: 'tr'}, - {label: '下右', value: 'tl'}, - {label: '左上', value: 'rb'}, - {label: '左下', value: 'rt'}, - {label: '右上', value: 'lb'}, - {label: '右下', value: 'lt'}, - ] - }, - closable: { - label: '关闭按钮', - value: 'true', - enum: [ - { - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - } - ] - } - } - }; - - this.onFunctionChange = this.onFunctionChange.bind(this); - } - - componentWillReceiveProps(nextProps) { - this.setState({ - demoFunction: { - align: { - ...this.state.demoFunction.align, - ...nextProps.align - }, - closable: { - ...this.state.demoFunction.closable, - ...nextProps.closable - }, - } - }); - } - - onFunctionChange(demoFunction) { - this.setState({ - demoFunction - }); - } - - render () { - const {content} = this.props; - const { demoFunction } = this.state; - const align = demoFunction.align.value; - const closable = demoFunction.closable.value === 'true'; - - return ( - - - - {content} - - - {content} - - - - - - - {content} - - - {content} - - - - ); - } -} - -class TooltipDemo extends React.Component { - constructor(props) { - super(props); - this.state = { - demoFunction: { - align: { - label: '箭头方向', - value: 'b', - enum: [ - {label: '上', value: 'b'}, - {label: '下', value: 't'}, - {label: '左', value: 'r'}, - {label: '右', value: 'l'}, - {label: '上左', value: 'br'}, - {label: '上右', value: 'bl'}, - {label: '下左', value: 'tr'}, - {label: '下右', value: 'tl'}, - {label: '左上', value: 'rb'}, - {label: '左下', value: 'rt'}, - {label: '右上', value: 'lb'}, - {label: '右下', value: 'lt'}, - ].slice(0, 8) - } - } - }; - - this.onFunctionChange = this.onFunctionChange.bind(this); - } - - componentWillReceiveProps(nextProps) { - this.setState({ - demoFunction: { - align: { - ...this.state.demoFunction.align, - ...nextProps.align - } - } - }); - } - - onFunctionChange(demoFunction) { - this.setState({ - demoFunction - }); - } - - render () { - const {content} = this.props; - const { demoFunction } = this.state; - const align = demoFunction.align.value; - - return ( - - - - {content} - - - - ); - } -} - - -function render(i18n) { - const tooltipContentText = i18n.tooltipContent; - const balloonContentText = i18n.balloonContent; - - return ReactDOM.render(( -
-

{i18n.title}

- - -
- ), document.getElementById('container')); -} - -window.renderDemo = function (lang = 'en-us') { - render(i18nMap[lang]); -}; - -window.renderDemo(); - - -initDemo('balloon'); diff --git a/components/balloon/__docs__/theme/index.tsx b/components/balloon/__docs__/theme/index.tsx new file mode 100644 index 0000000000..84b24e4211 --- /dev/null +++ b/components/balloon/__docs__/theme/index.tsx @@ -0,0 +1,355 @@ +import React, { Component, type ReactNode } from 'react'; +import ReactDOM from 'react-dom'; + +import '../../../demo-helper/style'; +import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; +import Balloon from '../../index'; +import '../../style'; +import '../../../icon/style'; +import type { AlignType } from '../../types'; + +const i18nMap: Record< + string, + { + title: string; + tooltipContent: string; + balloonContent: string; + align: AlignRecord; + closable: ClosableItem; + } +> = { + 'zh-cn': { + title: '气泡弹层', + tooltipContent: '提示浮层内可替换内容。', + balloonContent: '气泡浮层内可替换内容', + align: { + label: '箭头方向', + value: 'b', + enum: [ + { label: '上', value: 'b' }, + { label: '下', value: 't' }, + { label: '左', value: 'r' }, + { label: '右', value: 'l' }, + { label: '上左', value: 'br' }, + { label: '上右', value: 'bl' }, + { label: '下左', value: 'tr' }, + { label: '下右', value: 'tl' }, + { label: '左上', value: 'rb' }, + { label: '左下', value: 'rt' }, + { label: '右上', value: 'lb' }, + { label: '右下', value: 'lt' }, + ], + }, + closable: { + label: '关闭按钮', + value: 'true', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + }, + 'en-us': { + title: 'balloon', + tooltipContent: 'Tool tip content replace holder.', + balloonContent: 'Balloon content replace holder.', + align: { + label: 'direction', + value: 'b', + enum: [ + { label: 'top', value: 'b' }, + { label: 'bottom', value: 't' }, + { label: 'left', value: 'r' }, + { label: 'right', value: 'l' }, + { label: 'top left', value: 'br' }, + { label: 'top right', value: 'bl' }, + { label: 'bottom left', value: 'tr' }, + { label: 'bottom right', value: 'tl' }, + { label: 'left top', value: 'rb' }, + { label: 'left bottom', value: 'rt' }, + { label: 'right top', value: 'lb' }, + { label: 'right bottom', value: 'lt' }, + ], + }, + closable: { + label: 'closable', + value: 'true', + enum: [ + { + label: 'yes', + value: 'true', + }, + { + label: 'no', + value: 'false', + }, + ], + }, + }, +}; + +type AlignRecord = { + label: string; + value: AlignType; + enum: Array<{ label: string; value: AlignType }>; +}; + +type ClosableItem = { + label: string; + value: string; + enum: Array<{ label: string; value: string }>; +}; +interface BalloonDemoProps { + content: string; + align: AlignRecord; + closable: ClosableItem; +} + +interface BalloonDemoState { + demoFunction: { + align: AlignRecord; + closable: ClosableItem; + }; +} +class BalloonDemo extends Component { + constructor(props: BalloonDemoProps) { + super(props); + this.state = { + demoFunction: { + align: { + label: '箭头方向', + value: 'b', + enum: [ + { label: '上', value: 'b' }, + { label: '下', value: 't' }, + { label: '左', value: 'r' }, + { label: '右', value: 'l' }, + { label: '上左', value: 'br' }, + { label: '上右', value: 'bl' }, + { label: '下左', value: 'tr' }, + { label: '下右', value: 'tl' }, + { label: '左上', value: 'rb' }, + { label: '左下', value: 'rt' }, + { label: '右上', value: 'lb' }, + { label: '右下', value: 'lt' }, + ], + }, + closable: { + label: '关闭按钮', + value: 'true', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + }, + }; + + this.onFunctionChange = this.onFunctionChange.bind(this); + } + + componentWillReceiveProps(nextProps: BalloonDemoProps) { + this.setState({ + demoFunction: { + align: { + ...this.state.demoFunction.align, + ...nextProps.align, + }, + closable: { + ...this.state.demoFunction.closable, + ...nextProps.closable, + }, + }, + }); + } + + onFunctionChange(demoFunction: { align: AlignRecord; closable: ClosableItem }) { + this.setState({ + demoFunction, + }); + } + + render() { + const { content } = this.props; + const { demoFunction } = this.state; + const align = demoFunction.align.value; + const closable = demoFunction.closable.value === 'true'; + + return ( + + + + + {content} + + + {content} + + + + + + + {content} + + + {content} + + + + + ); + } +} +interface TooltipDemoProps { + content: ReactNode; + align: AlignRecord; + closable?: ClosableItem; +} + +interface TooltipDemoState { + demoFunction: { + align: AlignRecord; + }; +} +class TooltipDemo extends React.Component { + constructor(props: TooltipDemoProps) { + super(props); + this.state = { + demoFunction: { + align: { + label: '箭头方向', + value: 'b', + enum: [ + { label: '上', value: 'b' }, + { label: '下', value: 't' }, + { label: '左', value: 'r' }, + { label: '右', value: 'l' }, + { label: '上左', value: 'br' }, + { label: '上右', value: 'bl' }, + { label: '下左', value: 'tr' }, + { label: '下右', value: 'tl' }, + { label: '左上', value: 'rb' }, + { label: '左下', value: 'rt' }, + { label: '右上', value: 'lb' }, + { label: '右下', value: 'lt' }, + ].slice(0, 8) as { + label: string; + value: AlignType; + }[], + }, + }, + }; + + this.onFunctionChange = this.onFunctionChange.bind(this); + } + + componentWillReceiveProps(nextProps: TooltipDemoProps) { + this.setState({ + demoFunction: { + align: { + ...this.state.demoFunction.align, + ...nextProps.align, + }, + }, + }); + } + + onFunctionChange(demoFunction: TooltipDemoState['demoFunction']) { + this.setState({ + demoFunction, + }); + } + + render() { + const { content } = this.props; + const { demoFunction } = this.state; + const align = demoFunction.align.value; + + return ( + + + + + {content} + + + + + ); + } +} + +function render(i18n: { + title: string; + tooltipContent: string; + balloonContent: string; + align: AlignRecord; + closable: ClosableItem; +}) { + const tooltipContentText = i18n.tooltipContent; + const balloonContentText = i18n.balloonContent; + + ReactDOM.render( +
+

{i18n.title}

+ + +
, + document.getElementById('container') + ); +} + +window.renderDemo = function (lang = 'en-us') { + render(i18nMap[lang]); +}; + +window.renderDemo(); + +initDemo('balloon'); diff --git a/components/balloon/__tests__/a11y-spec.js b/components/balloon/__tests__/a11y-spec.js deleted file mode 100644 index cb2c1b7639..0000000000 --- a/components/balloon/__tests__/a11y-spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Balloon from '../index'; -import '../style'; -import { unmount, test, testReact, createContainer } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -const portalContainerId = 'a11y-portal-id'; -let portalContainer; - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Balloon A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - - if (portalContainer) { - portalContainer.remove(); - } - unmount(); - }); - - it('should not have any violations', async () => { - portalContainer = createContainer(portalContainerId); - wrapper = await testReact( - - I am balloon content - - ); - return test(portalContainer); - }); - - it('should not have any violations when not closable', async () => { - portalContainer = createContainer(portalContainerId); - wrapper = await testReact( - - I am balloon content - - ); - - return test(portalContainer); - }); - - it('should not have any violations when Tooltip', async () => { - portalContainer = createContainer(portalContainerId); - - wrapper = await testReact( - - I am balloon content - - ); - return test(portalContainer); - }); -}); diff --git a/components/balloon/__tests__/a11y-spec.tsx b/components/balloon/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..0d2b349e43 --- /dev/null +++ b/components/balloon/__tests__/a11y-spec.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import Balloon from '../index'; +import '../style'; +import { test, testReact, createContainer } from '../../util/__tests__/a11y/validate'; + +const portalContainerId = 'a11y-portal-id'; +let portalContainer: HTMLDivElement; + +describe('Balloon A11y', () => { + afterEach(() => { + if (portalContainer) { + portalContainer.remove(); + } + }); + it('should not have any violations', async () => { + portalContainer = createContainer(portalContainerId); + await testReact( + + I am balloon content + + ); + return test(portalContainer); + }); + + it('should not have any violations when not closable', async () => { + portalContainer = createContainer(portalContainerId); + await testReact( + + I am balloon content + + ); + + return test(portalContainer); + }); + + it('should not have any violations when Tooltip', async () => { + portalContainer = createContainer(portalContainerId); + + await testReact( + + I am balloon content + + ); + return test(portalContainer); + }); +}); diff --git a/components/balloon/__tests__/balloon-spec.js b/components/balloon/__tests__/balloon-spec.js deleted file mode 100644 index dbb9b66d7e..0000000000 --- a/components/balloon/__tests__/balloon-spec.js +++ /dev/null @@ -1,323 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Button from '../../button'; -import Balloon from '../index'; - -Enzyme.configure({ adapter: new Adapter() }); - -const defaultTrigger = ( - - trigger - -); - -const delay = time => new Promise(resolve => setTimeout(resolve, time)); - -describe('Balloon', () => { - let defaultWrapper = null; - - beforeEach(function() { - defaultWrapper = mount( - - i am balloon content - - ); - }); - - afterEach(function() { - defaultWrapper.unmount(); - }); - - describe('closable', () => { - it('closable: true', () => { - defaultWrapper.setProps({ - visible: true, - closable: true, - }); - assert(document.querySelector('.next-balloon-close') !== null); - }); - - it('closable: false', () => { - defaultWrapper.setProps({ - visible: true, - closable: false, - }); - assert(document.querySelector('.next-balloon-close') === null); - }); - }); - - describe('safeNode', () => { - it('safeNode', () => { - function Demo(props) { - return ( -
- - trigger} - id="balloon" - safeNode="safe" - triggerType="click" - > - i am balloon content - -
- ); - } - const wrapper = mount(); - wrapper.find('.balloon').simulate('click'); - wrapper.find('.safeButton').simulate('click'); - assert(document.querySelector('.next-balloon') !== null); - }); - }); - describe('type', () => { - it('type: normal', () => { - defaultWrapper.setProps({ - type: 'normal', - visible: true, - }); - assert(document.querySelector('.next-balloon-normal') !== null); - }); - it('type: primary', () => { - defaultWrapper.setProps({ - type: 'primary', - visible: true, - }); - assert(document.querySelector('.next-balloon-primary') !== null); - }); - }); - describe('trigger ,triggerType', () => { - it('should has the trigger element', () => { - assert(defaultWrapper.find('.triggerSpan').text() === 'trigger'); - }); - it('triggerType can set click', () => { - defaultWrapper.setProps({ - triggerType: 'click', - }); - defaultWrapper.find('span').simulate('click'); - assert(document.querySelector('.next-balloon') !== null); - }); - - //此处异步验证 - it('triggerType can set hover', async () => { - defaultWrapper.setProps({ - triggerType: 'hover', - }); - defaultWrapper.find('span').simulate('mouseenter'); - await delay(500); - assert(document.querySelector('.next-balloon') !== null); - }); - - // it('trigger is disabled button, hover enter and leave, popup should resolve', async () => { - // defaultWrapper.setProps({ - // trigger: ( - // - // ), - // triggerType: 'hover', - // }); - // // hover on the which is specially added for disabled pattern - // defaultWrapper.find('span').at(0).simulate('mouseenter'); - // await delay(500); - // defaultWrapper.update(); - // assert(document.querySelector('.next-balloon') !== null); - - // defaultWrapper.find('span').at(0).simulate('mouseleave'); - // await delay(600); - // defaultWrapper.update(); - // assert(document.querySelector('.next-balloon') === null); - // }); - - it('trigger can be string', async () => { - defaultWrapper.setProps({ - trigger: 'trigger', - triggerType: 'hover', - }); - defaultWrapper.find('span').simulate('mouseenter'); - await delay(300); - assert(document.querySelector('.next-balloon') !== null); - }); - - // trigger不传,默认用空的填充 - it('trigger default is span', () => { - const wrapper = mount(trigger); - assert(wrapper.find('span').length === 1); - }); - }); - - describe('align', () => { - it('balloon align', () => { - //top - const wrapperT = mount( - trigger} align="t" triggerType="click"> - i am balloon content -
- ); - wrapperT.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-bottom') !== null); - - const wrapperTL = mount( - trigger
} align="tl" triggerType="click"> - i am balloon content -
- ); - wrapperTL.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-bottom-right') !== null); - - const wrapperTR = mount( - trigger} align="tr" triggerType="click"> - i am balloon content - - ); - wrapperTR.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-bottom-left') !== null); - - //bottom - const wrapperB = mount( - trigger} align="b" triggerType="click"> - i am balloon content - - ); - wrapperB.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-top') !== null); - - const wrapperBL = mount( - trigger} align="bl" triggerType="click"> - i am balloon content - - ); - wrapperBL.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-top-right') !== null); - - const wrapperBR = mount( - trigger} align="br" triggerType="click"> - i am balloon content - - ); - wrapperBR.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-top-left') !== null); - - //left - const wrapperL = mount( - trigger} align="l" triggerType="click"> - i am balloon content - - ); - wrapperL.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-right') !== null); - - const wrapperLT = mount( - trigger} align="lt" triggerType="click"> - i am balloon content - - ); - wrapperLT.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-right-bottom') !== null); - const wrapperLB = mount( - trigger} align="lb" triggerType="click"> - i am balloon content - - ); - wrapperLB.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-right-top') !== null); - //right - const wrapperR = mount( - trigger} align="r" triggerType="click"> - i am balloon content - - ); - wrapperR.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-left') !== null); - - const wrapperRT = mount( - trigger} align="rt" triggerType="click"> - i am balloon content - - ); - wrapperRT.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-left-bottom') !== null); - const wrapperRB = mount( - trigger} align="rb" triggerType="click"> - i am balloon content - - ); - wrapperRB.find('span').simulate('click'); - assert(document.querySelector('.next-balloon-left-top') !== null); - }); - }); -}); - -describe('Balloon onClose ComponentWillReceiveProps closeIcon', () => { - it('onClose ComponentWillReceiveProps closeIcon', async () => { - //function afterCloseCallback(e){//afterClose无法测 - // time++; - //} - class App extends React.Component { - constructor(props) { - super(props); - this.state = { - visible: false, - }; - } - - hide() { - this.setState({ - visible: false, - }); - } - handleVisibleChange(visible) { - this.setState({ visible }); - } - - onClose() {} - - afterClose() {} - - render() { - const visibleTrigger = ( - - ); - - const content = ( -
- 点击按钮操作 -
- - 确认 - - - 关闭 - -
- ); - return ( -
- - {content} - -
- ); - } - } - const wrapper = mount(); - // console.log(wrapper.find('.trigger-btn').debug()); - wrapper.find('button').simulate('click'); - assert(document.querySelectorAll('.next-balloon') !== null); - document.querySelector('.next-balloon-close').click(); - await delay(1000); - assert(document.querySelector('.next-balloon') === null); - }); -}); diff --git a/components/balloon/__tests__/balloon-spec.tsx b/components/balloon/__tests__/balloon-spec.tsx new file mode 100644 index 0000000000..c4c8b2045e --- /dev/null +++ b/components/balloon/__tests__/balloon-spec.tsx @@ -0,0 +1,330 @@ +import React from 'react'; +import Button from '../../button'; +import Balloon from '../index'; +import '../style'; + +const defaultTrigger = ( + + trigger + +); + +describe('Balloon', () => { + describe('closable', () => { + it('closable: true', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-close').should('exist'); + }); + + it('closable: false', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-close').should('not.exist'); + }); + }); + + describe('safeNode', () => { + it('safeNode', () => { + function Demo() { + return ( +
+ + trigger} + id="balloon" + safeNode="safe" + triggerType="click" + > + i am balloon content + +
+ ); + } + cy.mount(); + cy.get('.balloon').trigger('click'); + cy.get('.safeButton').trigger('click'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon').should('exist'); + }); + }); + describe('type', () => { + it('type: normal', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-normal').should('exist'); + }); + it('type: primary', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-primary').should('exist'); + }); + }); + + describe('align', () => { + beforeEach(() => { + cy.mount( + trigger} align="t" triggerType="click"> + i am balloon content + + ).as('Demo'); + }); + + it('balloon align t', () => { + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom').should('exist'); + }); + it('balloon align tl', () => { + cy.rerender('Demo', { + align: 'tl', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom-right').should('exist'); + }); + it('balloon align tr', () => { + cy.rerender('Demo', { + align: 'tr', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom-left').should('exist'); + }); + it('balloon align b', () => { + //bottom + cy.rerender('Demo', { + align: 'b', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top').should('exist'); + }); + it('balloon align bl', () => { + cy.rerender('Demo', { + align: 'bl', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top-right').should('exist'); + }); + it('balloon align br', () => { + cy.rerender('Demo', { + align: 'br', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top-left').should('exist'); + }); + it('balloon align l', () => { + //left + cy.rerender('Demo', { + align: 'l', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right').should('exist'); + }); + it('balloon align lt', () => { + cy.rerender('Demo', { + align: 'lt', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right-bottom').should('exist'); + }); + it('balloon align lb', () => { + cy.rerender('Demo', { + align: 'lb', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right-top').should('exist'); + }); + it('balloon align r', () => { + //right + cy.rerender('Demo', { + align: 'r', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left').should('exist'); + }); + it('balloon align rt', () => { + cy.rerender('Demo', { + align: 'rt', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left-bottom').should('exist'); + }); + it('balloon align rb', () => { + cy.rerender('Demo', { + align: 'rb', + }); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left-top').should('exist'); + }); + }); + + describe('Balloon onClose ComponentWillReceiveProps closeIcon', () => { + it('onClose ComponentWillReceiveProps closeIcon', () => { + interface AppProps {} + class App extends React.Component { + constructor(props: AppProps) { + super(props); + this.state = { + visible: false, + }; + } + + hide() { + this.setState({ + visible: false, + }); + } + handleVisibleChange(visible: boolean) { + this.setState({ visible }); + } + + onClose() {} + + afterClose() {} + + render() { + const visibleTrigger = ( + + ); + + const content = ( +
+ 点击按钮操作 +
+ + 确认 + + + 关闭 + +
+ ); + return ( +
+ + {content} + +
+ ); + } + } + cy.mount(); + cy.get('.trigger-btn').trigger('click'); + cy.get('.next-balloon').should('exist'); + cy.get('.next-balloon-close').trigger('click'); + cy.get('.next-balloon').should('not.exist'); + }); + }); + + describe('trigger ,triggerType', () => { + it('should has the trigger element', () => { + cy.mount( + + i am balloon content + + ); + + cy.get('.triggerSpan').should('have.text', 'trigger'); + }); + it('triggerType can set click', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('click'); + cy.get('.next-balloon').should('exist'); + }); + + it('triggerType can set hover', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-balloon').should('exist'); + }); + + it('trigger can be string', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon').should('exist'); + }); + + // trigger 不传,默认用空的填充 + it('trigger default is span', () => { + cy.mount(trigger); + cy.get('span').should('have.length', 1); + }); + }); +}); diff --git a/components/balloon/__tests__/balloon-v2-spec.js b/components/balloon/__tests__/balloon-v2-spec.js deleted file mode 100644 index c6ed9d2340..0000000000 --- a/components/balloon/__tests__/balloon-v2-spec.js +++ /dev/null @@ -1,289 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import snion from 'sinon'; -import assert from 'power-assert'; -import Button from '../../button'; -import Balloon from '../index'; - -Enzyme.configure({ adapter: new Adapter() }); - -const defaultTrigger = ( - - trigger - -); - -const delay = time => new Promise(resolve => setTimeout(resolve, time)); - -describe('Balloon v2', () => { - let defaultWrapper = null; - - beforeEach(function() { - defaultWrapper = mount( - - i am balloon content - - ); - }); - - afterEach(function() { - defaultWrapper.unmount(); - const nodeListArr = [].slice.call(document.querySelectorAll('.next-balloon')); - nodeListArr.forEach((node, index) => { - node.parentNode.removeChild(node); - }); - }); - describe('closable', () => { - it('closable: true', async () => { - defaultWrapper.setProps({ - visible: true, - closable: true, - }); - await delay(20); - assert(document.querySelector('.next-balloon-close') !== null); - }); - - it('closable: false', () => { - defaultWrapper.setProps({ - visible: true, - closable: false, - }); - assert(document.querySelector('.next-balloon-close') === null); - }); - }); - - describe('safeNode', () => { - it('safeNode', async () => { - function Demo(props) { - return ( -
- - trigger} - id="balloon" - safeNode="safe" - triggerType="click" - > - i am balloon content - -
- ); - } - const wrapper = mount(); - wrapper.find('.balloon').simulate('click'); - wrapper.find('.safeButton').simulate('click'); - await delay(20); - assert(document.querySelector('.next-balloon') !== null); - }); - }); - describe('type', () => { - it('type: normal', async () => { - defaultWrapper.setProps({ - type: 'normal', - visible: true, - }); - await delay(20); - assert(document.querySelector('.next-balloon-normal') !== null); - }); - it('type: primary', async () => { - defaultWrapper.setProps({ - type: 'primary', - visible: true, - }); - await delay(20); - assert(document.querySelector('.next-balloon-primary') !== null); - }); - }); - describe('trigger ,triggerType', () => { - it('should has the trigger element', () => { - assert(defaultWrapper.find('.triggerSpan').text() === 'trigger'); - }); - it('triggerType can set click', async () => { - defaultWrapper.setProps({ - triggerType: 'click', - }); - defaultWrapper.find('span').simulate('click'); - await delay(20); - assert(document.querySelector('.next-balloon') !== null); - }); - - //此处异步验证 - it('triggerType can set hover', async () => { - defaultWrapper.setProps({ - triggerType: 'hover', - }); - defaultWrapper.find('span').simulate('mouseenter'); - await delay(500); - assert(document.querySelector('.next-balloon') !== null); - }); - - it('trigger can be string', async () => { - defaultWrapper.setProps({ - trigger: 'trigger', - triggerType: 'hover', - }); - defaultWrapper.find('span').simulate('mouseenter'); - await delay(300); - assert(document.querySelector('.next-balloon') !== null); - }); - - // trigger不传,默认用空的填充 - it('trigger default is span', () => { - const wrapper = mount( - - trigger - - ); - assert(wrapper.find('span').length === 1); - }); - }); -}); - -describe('balloon v2', () => { - // 弹窗的关键就是要清理掉遗留的元素 - afterEach(function() { - const nodeListArr = [].slice.call(document.querySelectorAll('.next-balloon')); - nodeListArr.forEach((node, index) => { - node && node.parentNode && node.parentNode.removeChild(node); - }); - }); - it('balloon align', async () => { - //top - const wrapperT = mount( - trigger} align="t" triggerType="click"> - i am balloon content - - ); - wrapperT.find('span').simulate('click'); - await delay(20); - assert(document.querySelector('.next-balloon-bottom') !== null); - - const wrapperTL = mount( - trigger} align="tl" triggerType="click"> - i am balloon content - - ); - wrapperTL.find('span').simulate('click'); - await delay(20); - console.log(document.querySelectorAll('.next-balloon')); - assert(document.querySelector('.next-balloon-bottom-left') !== null); - - const wrapperTR = mount( - trigger} align="tr" triggerType="click"> - i am balloon content - - ); - wrapperTR.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-bottom-right') !== null); - - //bottom - const wrapperB = mount( - trigger} align="b" triggerType="click"> - i am balloon content - - ); - wrapperB.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-top') !== null); - - const wrapperBL = mount( - trigger} align="bl" triggerType="click"> - i am balloon content - - ); - wrapperBL.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-top-left') !== null); - - const wrapperBR = mount( - trigger} align="br" triggerType="click"> - i am balloon content - - ); - wrapperBR.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-top-right') !== null); - - //left - const wrapperL = mount( - trigger} align="l" triggerType="click"> - i am balloon content - - ); - wrapperL.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-right') !== null); - - const wrapperLT = mount( - trigger} align="lt" triggerType="click"> - i am balloon content - - ); - wrapperLT.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-right-top') !== null); - const wrapperLB = mount( - trigger} align="lb" triggerType="click"> - i am balloon content - - ); - wrapperLB.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-right-bottom') !== null); - //right - const wrapperR = mount( - trigger} align="r" triggerType="click"> - i am balloon content - - ); - wrapperR.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-left') !== null); - - const wrapperRT = mount( - trigger} align="rt" triggerType="click"> - i am balloon content - - ); - wrapperRT.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-left-top') !== null); - const wrapperRB = mount( - trigger} align="rb" triggerType="click"> - i am balloon content - - ); - wrapperRB.find('span').simulate('click'); - await delay(20); - - assert(document.querySelector('.next-balloon-left-bottom') !== null); - }); - it('onClose shuld be called with closeIcon', async () => { - const onClose = snion.spy(); - const wrapper = mount( - trigger} align="rb" triggerType="click" onClose={onClose}> - i am balloon content - - ); - wrapper.find('button').simulate('click'); - await delay(20); - assert(document.querySelector('.next-balloon') !== null); - document.querySelector('.next-balloon-close').click(); - await delay(20); - assert(onClose.calledOnce); - }); -}); diff --git a/components/balloon/__tests__/balloon-v2-spec.tsx b/components/balloon/__tests__/balloon-v2-spec.tsx new file mode 100644 index 0000000000..12884b7e9f --- /dev/null +++ b/components/balloon/__tests__/balloon-v2-spec.tsx @@ -0,0 +1,457 @@ +import React from 'react'; +import Balloon from '../index'; +import '../style'; + +const defaultTrigger = ( + + trigger + +); + +describe('balloon v2', () => { + it('balloon align t', () => { + cy.mount( +
+ trigger} + align="t" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom').should('exist'); + }); + it('balloon align tl', () => { + cy.mount( +
+ trigger} + align="tl" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom-left').should('exist'); + }); + it('balloon align tr', () => { + cy.mount( +
+ trigger} + align="tr" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-bottom-right').should('exist'); + }); + it('balloon align b', () => { + //bottom + + cy.mount( +
+ trigger} + align="b" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top').should('exist'); + }); + it('balloon align bl', () => { + cy.mount( +
+ trigger} + align="bl" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top-left').should('exist'); + }); + it('balloon align br', () => { + cy.mount( +
+ trigger} + align="br" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-top-right').should('exist'); + }); + it('balloon align l', () => { + //left + cy.mount( +
+ trigger} + align="l" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right').should('exist'); + }); + it('balloon align lt', () => { + cy.mount( +
+ trigger} + align="lt" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right-top').should('exist'); + }); + it('balloon align lb', () => { + cy.mount( +
+ trigger} + align="lb" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-right-bottom').should('exist'); + }); + it('balloon align r', () => { + //right + cy.mount( +
+ trigger} + align="r" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left').should('exist'); + }); + it('balloon align rt', () => { + cy.mount( +
+ trigger} + align="rt" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left-top').should('exist'); + }); + it('balloon align rb', () => { + cy.mount( +
+ trigger} + align="rb" + autoAdjust={false} + triggerType="click" + > + i am balloon content + +
+ ); + cy.get('span').trigger('click'); + cy.get('.next-balloon-left-bottom').should('exist'); + }); + + it('onClose shuld be called with closeIcon', () => { + const onClose = cy.spy().as('onClose'); + cy.mount( +
+ trigger} + align="rb" + triggerType="click" + onClose={onClose} + > + i am balloon content + +
+ ); + cy.get('button').trigger('click'); + cy.get('.next-balloon-close').should('exist'); + + cy.get('.next-balloon-close').trigger('click'); + cy.wrap(onClose).should('have.been.calledOnce'); + }); +}); + +describe('Balloon v2', () => { + describe('closable', () => { + it('closable: true', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-close').should('exist'); + }); + + it('closable: false', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-close').should('not.exist'); + }); + }); + + describe('safeNode', () => { + it('safeNode', () => { + function Demo() { + return ( +
+ + trigger} + id="balloon" + safeNode="safe" + triggerType="click" + v2 + > + i am balloon content + +
+ ); + } + cy.mount(); + cy.get('.balloon').trigger('click'); + cy.get('.safeButton').trigger('click'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon').should('exist'); + }); + }); + describe('type', () => { + it('type: normal', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-normal').should('exist'); + }); + it('type: primary', () => { + cy.mount( + + i am balloon content + + ); + cy.get('.next-balloon-primary').should('exist'); + }); + }); + describe('trigger ,triggerType', () => { + it('should has the trigger element', () => { + cy.mount( + + i am balloon content + + ); + + cy.get('.triggerSpan').should('have.text', 'trigger'); + }); + it('triggerType can set click', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('click'); + cy.get('.next-balloon').should('exist'); + }); + + it('triggerType can set hover', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon').should('exist'); + }); + + it('trigger can be string', () => { + cy.mount( + + i am balloon content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon').should('exist'); + }); + + // trigger 不传,默认用空的填充 + it('trigger default is span', () => { + cy.mount( + + trigger + + ); + cy.get('span').should('have.length', 1); + }); + + it('default offset should be 12px', () => { + const trigger = ( +
+ trigger +
+ ); + cy.mount( + + trigger + + ).as('Demo'); + cy.get('.trigger').then($el => { + const triggerRect = $el[0].getBoundingClientRect(); + expect(Math.round(triggerRect.bottom + 12)).to.equal( + Math.round(document.querySelector('.next-balloon')!.getBoundingClientRect().top) + ); + }); + cy.rerender('Demo', { align: 't' }); + cy.get('.trigger').then($el => { + const triggerRect = $el[0].getBoundingClientRect(); + expect(Math.round(triggerRect.top - 12)).to.equal( + Math.round( + document.querySelector('.next-balloon')!.getBoundingClientRect().bottom + ) + ); + }); + cy.rerender('Demo', { align: 'l' }); + cy.get('.trigger').then($el => { + const triggerRect = $el[0].getBoundingClientRect(); + expect(Math.round(triggerRect.left - 12)).to.equal( + Math.round( + document.querySelector('.next-balloon')!.getBoundingClientRect().right + ) + ); + }); + cy.rerender('Demo', { align: 'r' }); + cy.get('.trigger').then($el => { + const triggerRect = $el[0].getBoundingClientRect(); + expect(Math.round(triggerRect.right + 12)).to.equal( + Math.round( + document.querySelector('.next-balloon')!.getBoundingClientRect().left + ) + ); + }); + }); + }); +}); diff --git a/components/balloon/__tests__/inner-spec.js b/components/balloon/__tests__/inner-spec.js deleted file mode 100644 index 1054f786ac..0000000000 --- a/components/balloon/__tests__/inner-spec.js +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Balloon from '../index'; - -/* eslint-disable react/no-multi-comp */ - -Enzyme.configure({ adapter: new Adapter() }); -const Inner = Balloon.Inner; - -describe('Tooltip', () => { - it('balloon', () => { - const wrapper = mount(test); - assert(wrapper.find('.next-balloon').length === 1); - }); - - it('tooltip', () => { - const wrapper = mount(test); - assert(wrapper.find('.next-balloon-tooltip').length === 1); - }); -}); diff --git a/components/balloon/__tests__/inner-spec.tsx b/components/balloon/__tests__/inner-spec.tsx new file mode 100644 index 0000000000..b1ecb99f7d --- /dev/null +++ b/components/balloon/__tests__/inner-spec.tsx @@ -0,0 +1,17 @@ +import React from 'react'; +import Balloon from '../index'; +import '../style'; + +const Inner = Balloon.Inner; + +describe('Tooltip', () => { + it('balloon', () => { + cy.mount(test); + cy.get('.next-balloon').should('exist'); + }); + + it('tooltip', () => { + cy.mount(test); + cy.get('.next-balloon-tooltip').should('exist'); + }); +}); diff --git a/components/balloon/__tests__/issue-spec.js b/components/balloon/__tests__/issue-spec.js deleted file mode 100644 index 5a7e0738f6..0000000000 --- a/components/balloon/__tests__/issue-spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import ReactTestUtils from 'react-dom/test-utils'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Balloon from '../index'; - -Enzyme.configure({ adapter: new Adapter() }); - -function wait(duration) { - return new Promise(resolve => { - setTimeout(resolve, duration); - }); -} - -describe('Balloon issues', function() { - describe('https://github.com/alibaba-fusion/next/issues/4137', function() { - it('autoAdjust when in the fixed box and followTrigger=true', async function() { - const rootNode = document.createElement('div'); - document.body.appendChild(rootNode); - const wrapper = mount( -
- trigger} - align="t" - followTrigger - animation={false} - > - long overlay content,long overlay content,long overlay content,long overlay content,long overlay - content,long overlay content,long overlay content - -
, - { attachTo: rootNode } - ); - const trigger = rootNode.querySelector('.trigger'); - assert(trigger); - ReactTestUtils.Simulate.click(trigger); - await wait(100); - const overlay = rootNode.querySelector('.next-balloon'); - assert(overlay); - const rect = overlay.getBoundingClientRect(); - // will adjust into viewport - assert(rect.left >= 0); - assert(rect.top + rect.height + trigger.offsetHeight < document.documentElement.clientHeight); - wrapper.unmount(); - document.body.removeChild(rootNode); - }); - it('autoAdjust when in the fixed box and followTrigger=false', async function() { - const rootNode = document.createElement('div'); - document.body.appendChild(rootNode); - const overlayClassName = `overlay-${Math.random() - .toString(36) - .slice(2)}`; - const wrapper = mount( -
- trigger} - align="t" - popupClassName={overlayClassName} - animation={false} - > - long overlay content,long overlay content,long overlay content,long overlay content,long overlay - content,long overlay content,long overlay content - -
, - { attachTo: rootNode } - ); - const trigger = rootNode.querySelector('.trigger'); - assert(trigger); - ReactTestUtils.Simulate.click(trigger); - await wait(100); - // adjust to tl, will render .next-balloon-bottom-left - assert(document.querySelector(`.${overlayClassName}.next-balloon-bottom-left`)); - wrapper.unmount(); - document.body.removeChild(rootNode); - }); - }); -}); diff --git a/components/balloon/__tests__/issue-spec.tsx b/components/balloon/__tests__/issue-spec.tsx new file mode 100644 index 0000000000..c92f214956 --- /dev/null +++ b/components/balloon/__tests__/issue-spec.tsx @@ -0,0 +1,106 @@ +import React from 'react'; +import Balloon from '../index'; +import '../style'; + +describe('Balloon issues', function () { + describe('https://github.com/alibaba-fusion/next/issues/4137', function () { + it('autoAdjust when in the fixed box and followTrigger=true', function () { + cy.mount( +
+
+ trigger} + align="t" + followTrigger + animation={false} + > + long overlay content,long overlay content,long overlay content,long + overlay content,long overlay content,long overlay content,long overlay + content + +
+
+ ); + cy.get('.trigger').trigger('click'); + cy.get('.next-balloon').should('exist'); + + cy.get('.next-balloon') + .should('exist') + .then($overlay => { + const rect = $overlay[0].getBoundingClientRect(); + + expect(rect.left > 0); + const triggerHeight = document.querySelector('.trigger')!.clientHeight; + expect( + rect.top + rect.height + triggerHeight! < + document.documentElement.clientHeight + ); + }); + }); + it('autoAdjust when in the fixed box and followTrigger=false', function () { + const overlayClassName = `overlay-${Math.random().toString(36).slice(2)}`; + cy.mount( +
+
+ trigger} + align="t" + popupClassName={overlayClassName} + animation={false} + followTrigger={false} + > + long overlay content,long overlay content,long overlay content,long + overlay content,long overlay content,long overlay content,long overlay + content + +
+
+ ); + + cy.get('button').trigger('click'); + cy.get(`.${overlayClassName}.next-balloon-bottom-left`).should('exist'); + }); + }); +}); + +describe('balloon delay', () => { + it.only('add mouseEnterDelay and mouseLeaveDelay, with higher priority than delay.', () => { + cy.mount( + trigger1111111
} + delay={500} + mouseEnterDelay={1000} + mouseLeaveDelay={1000} + triggerType="hover" + > + trigger + + ); + + cy.get('.trigger').trigger('mouseover'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-balloon').should('not.exist'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(550); + cy.get('.next-balloon').should('exist'); + + cy.get('.trigger').trigger('mouseout'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-balloon').should('exist'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-balloon').should('not.exist'); + }); +}); diff --git a/components/balloon/__tests__/tooltip-spec.js b/components/balloon/__tests__/tooltip-spec.js deleted file mode 100644 index 5d9a1bdd87..0000000000 --- a/components/balloon/__tests__/tooltip-spec.js +++ /dev/null @@ -1,102 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Button from '../../button'; -import Balloon from '../index'; - -/* eslint-disable react/no-multi-comp */ - -// import Button from '../../src/button'; - -Enzyme.configure({ adapter: new Adapter() }); -const Tooltip = Balloon.Tooltip; -const trigger = ( - {}}> - xiachi - -); -describe('Tooltip', () => { - let defaultWrapper = {}; - - beforeEach(function() { - defaultWrapper = mount( - - i am tooltip content - - ); - }); - afterEach(function() { - defaultWrapper.unmount(); - const nodeListArr = [].slice.call(document.querySelectorAll('.next-balloon-tooltip')); - nodeListArr.forEach((node, index) => { - node.parentNode.removeChild(node); - }); - }); - // trigger不传,默认用空的填充 - it('trigger default is span', () => { - const wrapper = mount(test); - // console.log(wrapper.debug()); - assert(wrapper.find('span').length === 1); - }); - - // it('tooltip should trigger on hover', (done) => { - // defaultWrapper.find('.trigger').simulate('mouseenter'); - // // setTimeout(function() { - // assert(document.querySelector('.next-balloon-tooltip') !== null); - // // done(); - // // }, 500); - // }); - it('tooltip should have the trigger element', () => { - assert(defaultWrapper.find('.trigger').text() === 'xiachi'); - }); - - it('text not string should throw an error', () => { - try { - defaultWrapper.setProps({ - text: 2, - }); - } catch (e) { - assert(e instanceof Error); - } - }); - - it('trigger is disabled button, hover enter and leave, popup should resolve', done => { - defaultWrapper.setProps({ - trigger: ( - - ), - }); - // hover on the which is specially added for disabled pattern - defaultWrapper - .find('span') - .at(0) - .simulate('mouseenter'); - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') !== null); - - defaultWrapper - .find('span') - .at(0) - .simulate('mouseleave'); - - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') === null); - done(); - }, 600); - }, 300); - }); - - it('trigger can be string', done => { - defaultWrapper.setProps({ - trigger: 'trigger', - }); - defaultWrapper.find('span').simulate('mouseenter'); - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') !== null); - done(); - }, 300); - }); -}); diff --git a/components/balloon/__tests__/tooltip-spec.tsx b/components/balloon/__tests__/tooltip-spec.tsx new file mode 100644 index 0000000000..336494c59a --- /dev/null +++ b/components/balloon/__tests__/tooltip-spec.tsx @@ -0,0 +1,100 @@ +import React from 'react'; +import Balloon from '../index'; +import Button from '../../button'; +import '../style'; + +const Tooltip = Balloon.Tooltip; +const trigger = ( + {}}> + xiachi + +); +describe('Tooltip', () => { + // trigger 不传,默认用空的填充 + it('trigger default is span', () => { + cy.mount(test); + cy.get('span').should('have.length', 1); + }); + + it('tooltip should have the trigger element', () => { + cy.mount( + + i am tooltip content + + ); + cy.get('.trigger').should('have.text', 'xiachi'); + }); + + it('text not string should throw an error', () => { + cy.on('uncaught:exception', err => { + expect(err).to.be.an.instanceOf(Error); + return false; + }); + cy.mount( + + i am tooltip content + + ); + }); + + it('trigger is disabled button, hover enter and leave, popup should not resolve', () => { + cy.mount( + + button + + } + triggerType="hover" + > + i am tooltip content + + ); + cy.get('span').eq(0).trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon-tooltip').should('exist'); + }); + + it('trigger can be string', () => { + cy.mount( + + i am tooltip content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon-tooltip').should('exist'); + }); + + it('add mouseEnterDelay and mouseLeaveDelay, with higher priority than delay.', () => { + cy.mount( + trigger1111111
} + delay={500} + mouseEnterDelay={1000} + mouseLeaveDelay={1000} + > + test + + ); + cy.get('.trigger').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-balloon-tooltip').should('not.exist'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(550); + cy.get('.next-balloon-tooltip').should('exist'); + + cy.get('.trigger').trigger('mouseout'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-balloon-tooltip').should('exist'); + + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-balloon-tooltip').should('not.exist'); + }); +}); diff --git a/components/balloon/__tests__/tooltip-v2-spec.js b/components/balloon/__tests__/tooltip-v2-spec.js deleted file mode 100644 index d754153ce9..0000000000 --- a/components/balloon/__tests__/tooltip-v2-spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Button from '../../button'; -import Balloon from '../index'; - -/* eslint-disable react/no-multi-comp */ - -// import Button from '../../src/button'; - -Enzyme.configure({ adapter: new Adapter() }); -const Tooltip = Balloon.Tooltip; -const trigger = ( - {}}> - xiachi - -); -describe('Tooltip v2', () => { - let defaultWrapper = {}; - - beforeEach(function() { - defaultWrapper = mount( - - i am tooltip content - - ); - }); - afterEach(function() { - defaultWrapper.unmount(); - const nodeListArr = [].slice.call(document.querySelectorAll('.next-balloon-tooltip')); - nodeListArr.forEach((node, index) => { - node.parentNode.removeChild(node); - }); - }); - after(function() { - const nodeListArr = [].slice.call(document.querySelectorAll('.next-overlay-wrapper')); - nodeListArr.forEach((node, index) => { - node.parentNode.removeChild(node); - }); - }); - - // trigger不传,默认用空的填充 - it('trigger default is span', () => { - const wrapper = mount(test); - // console.log(wrapper.debug()); - assert(wrapper.find('span').length === 1); - }); - - // it('tooltip should trigger on hover', (done) => { - // defaultWrapper.find('.trigger').simulate('mouseenter'); - // // setTimeout(function() { - // assert(document.querySelector('.next-balloon-tooltip') !== null); - // // done(); - // // }, 500); - // }); - it('tooltip should have the trigger element', () => { - assert(defaultWrapper.find('.trigger').text() === 'xiachi'); - }); - - it('text not string should throw an error', () => { - try { - defaultWrapper.setProps({ - text: 2, - }); - } catch (e) { - assert(e instanceof Error); - } - }); - - it('trigger is disabled button, hover enter and leave, popup should resolve', done => { - defaultWrapper.setProps({ - trigger: ( - - ), - }); - // hover on the which is specially added for disabled pattern - defaultWrapper - .find('span') - .at(0) - .simulate('mouseenter'); - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') !== null); - - defaultWrapper - .find('span') - .at(0) - .simulate('mouseleave'); - - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') === null); - done(); - }, 600); - }, 300); - }); - - it('trigger can be string', done => { - defaultWrapper.setProps({ - trigger: 'trigger', - }); - defaultWrapper.find('span').simulate('mouseenter'); - setTimeout(function() { - assert(document.querySelector('.next-balloon-tooltip') !== null); - done(); - }, 300); - }); -}); diff --git a/components/balloon/__tests__/tooltip-v2-spec.tsx b/components/balloon/__tests__/tooltip-v2-spec.tsx new file mode 100644 index 0000000000..3d8ea623de --- /dev/null +++ b/components/balloon/__tests__/tooltip-v2-spec.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import Balloon from '../index'; +import Button from '../../button'; +import '../style'; + +const Tooltip = Balloon.Tooltip; +const trigger = ( + {}}> + xiachi + +); +describe('Tooltip v2', () => { + // trigger 不传,默认用空的填充 + it('trigger default is span', () => { + cy.mount(test); + cy.get('span').should('have.length', 1); + }); + + it('tooltip should have the trigger element', () => { + cy.mount( + + i am tooltip content + + ); + cy.get('.trigger').should('have.text', 'xiachi'); + }); + + it('text not string should throw an error', () => { + cy.on('uncaught:exception', err => { + expect(err).to.be.an.instanceOf(Error); + return false; + }); + cy.mount( + + i am tooltip content + + ); + }); + + it('trigger is disabled button, hover enter and leave, popup should not resolve', () => { + cy.mount( + + button + + } + triggerType="hover" + > + i am tooltip content + + ); + cy.get('span').eq(0).trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon-tooltip').should('exist'); + }); + + it('trigger can be string', () => { + cy.mount( + + i am tooltip content + + ); + cy.get('span').trigger('mouseover'); + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(300); + cy.get('.next-balloon-tooltip').should('exist'); + }); +}); diff --git a/components/balloon/alignMap.js b/components/balloon/alignMap.ts similarity index 100% rename from components/balloon/alignMap.js rename to components/balloon/alignMap.ts diff --git a/components/balloon/balloon.jsx b/components/balloon/balloon.jsx deleted file mode 100644 index c8e86089e7..0000000000 --- a/components/balloon/balloon.jsx +++ /dev/null @@ -1,456 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import Overlay from '../overlay'; -import { func, obj, log } from '../util'; -import BalloonInner from './inner'; -import { normalMap, edgeMap } from './alignMap'; -import { getDisabledCompatibleTrigger } from './util'; - -const { noop } = func; -const { Popup } = Overlay; - -const alignList = ['t', 'r', 'b', 'l', 'tl', 'tr', 'bl', 'br', 'lt', 'lb', 'rt', 'rb']; - -let alignMap = normalMap; - -/** Balloon */ -class Balloon extends React.Component { - static contextTypes = { - prefix: PropTypes.string, - }; - static propTypes = { - prefix: PropTypes.string, - pure: PropTypes.bool, - rtl: PropTypes.bool, - /** - * 自定义类名 - */ - className: PropTypes.string, - /** - * 自定义内敛样式 - */ - style: PropTypes.object, - /** - * 浮层的内容 - */ - children: PropTypes.any, - size: PropTypes.string, - /** - * 样式类型 - */ - type: PropTypes.oneOf(['normal', 'primary']), - /** - * 标题 - * @version 1.23 - */ - title: PropTypes.node, - /** - * 弹层当前显示的状态 - */ - visible: PropTypes.bool, - /** - * 弹层默认显示的状态 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层在显示和隐藏触发的事件 - * @param {Boolean} visible 弹层是否隐藏和显示 - * @param {String} type 触发弹层显示或隐藏的来源, closeClick 表示由自带的关闭按钮触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - alignEdge: PropTypes.bool, - /** - * 开启 v2 版本 - * @version 1.25 - */ - v2: PropTypes.bool, - /** - * [v2] 箭头是否指向目标元素的中心 - * @version 1.25 - */ - arrowPointToCenter: PropTypes.bool, - /** - * [v2] 弹层偏离触发元素的像素值 - */ - placementOffset: PropTypes.number, - /** - * 是否显示关闭按钮 - */ - closable: PropTypes.bool, - /** - * 弹出层位置 - * @enumdesc 上, 右, 下, 左, 上左, 上右, 下左, 下右, 左上, 左下, 右上, 右下 - */ - align: PropTypes.oneOf(alignList), - /** - * 弹层相对于trigger的定位的微调, 接收数组[hoz, ver], 表示弹层在 left / top 上的增量 - * e.g. [100, 100] 表示往右(RTL 模式下是往左) 、下分布偏移100px - */ - offset: PropTypes.array, - /** - * 触发元素 - */ - trigger: PropTypes.any, - /** - * 触发行为 - * 鼠标悬浮, 鼠标点击('hover','click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用click - */ - triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - - onClick: PropTypes.func, - /** - * 任何visible为false时会触发的事件 - */ - onClose: PropTypes.func, - onHover: PropTypes.func, - /** - * [v2] 是否进行自动位置调整,默认自动开启。 - * @version 1.25 - */ - autoAdjust: PropTypes.bool, - needAdjust: PropTypes.bool, - /** - * 弹层在触发以后的延时显示, 单位毫秒 ms - */ - delay: PropTypes.number, - /** - * 浮层关闭后触发的事件, 如果有动画,则在动画结束后触发 - */ - afterClose: PropTypes.func, - shouldUpdatePosition: PropTypes.bool, - /** - * 弹层出现后是否自动focus到内部第一个元素 - */ - autoFocus: PropTypes.bool, - /** - * 安全节点:对于triggetType为click的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode用于添加不触发关闭的节点, 值可以是dom节点的id或者是节点的dom对象 - */ - safeNode: PropTypes.string, - /** - * 用来指定safeNode节点的id,和safeNode配合使用 - */ - safeId: PropTypes.string, - /** - * 配置动画的播放方式,格式是{in: '', out: ''}, 常用的动画class请查看Animate组件文档 - * @param {String} in 进场动画 - * @param {String} out 出场动画 - */ - animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - - /** - * 弹层的dom节点关闭时是否删除 - */ - cache: PropTypes.bool, - /** - * 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 - */ - popupContainer: PropTypes.any, - container: PropTypes.any, - /** - * 弹层组件style,透传给Popup - */ - popupStyle: PropTypes.object, - /** - * 弹层组件className,透传给Popup - */ - popupClassName: PropTypes.string, - /** - * 弹层组件属性,透传给Popup - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 弹层id, 传入值才会支持无障碍 - */ - id: PropTypes.string, - }; - static defaultProps = { - prefix: 'next-', - pure: false, - type: 'normal', - closable: true, - defaultVisible: false, - size: 'medium', - alignEdge: false, - arrowPointToCenter: false, - align: 'b', - offset: [0, 0], - trigger: , - onClose: noop, - afterClose: noop, - onVisibleChange: noop, - needAdjust: false, - triggerType: 'hover', - safeNode: undefined, - safeId: null, - autoFocus: true, - animation: { - in: 'zoomIn zoomInBig', - out: 'zoomOut zoomOutBig', - }, - cache: false, - popupStyle: {}, - popupClassName: '', - popupProps: {}, - }; - - constructor(props, context) { - super(props, context); - this.state = { - align: alignList.includes(props.align) ? props.align : 'b', - visible: 'visible' in props ? props.visible : props.defaultVisible, - }; - this._onClose = this._onClose.bind(this); - this._onPosition = this._onPosition.bind(this); - this._onVisibleChange = this._onVisibleChange.bind(this); - } - - static getDerivedStateFromProps(nextProps, prevState) { - const nextState = {}; - if ('visible' in nextProps) { - nextState.visible = nextProps.visible; - } - - if ( - !prevState.innerAlign && - 'align' in nextProps && - alignList.includes(nextProps.align) && - nextProps.align !== prevState.align - ) { - nextState.align = nextProps.align; - nextState.innerAlign = false; - } - - return nextState; - } - - _onVisibleChange(visible, trigger) { - // Not Controlled - if (!('visible' in this.props)) { - this.setState({ - visible: visible, - }); - } - - this.props.onVisibleChange(visible, trigger); - - if (!visible) { - this.props.onClose(); - } - } - - _onClose(e) { - this._onVisibleChange(false, 'closeClick'); - - //必须加上preventDefault,否则单测IE下报错,出现full page reload 异常 - e.preventDefault(); - } - - _onPosition(res) { - const { rtl } = this.props; - alignMap = this.props.alignEdge ? edgeMap : normalMap; - const newAlign = res.align.join(' '); - let resAlign; - - let alignKey = 'align'; - if (rtl) { - alignKey = 'rtlAlign'; - } - - for (const key in alignMap) { - if (alignMap[key][alignKey] === newAlign) { - resAlign = key; - - break; - } - } - - resAlign = resAlign || this.state.align; - if (resAlign !== this.state.align) { - this.setState({ - align: resAlign, - innerAlign: true, - }); - } - } - - beforePosition = (result, obj) => { - const { placement } = result.config; - if (placement !== this.state.align) { - this.setState({ - align: placement, - innerAlign: true, - }); - } - - if (this.props.arrowPointToCenter) { - const { width, height } = obj.target; - if (placement.length === 2) { - const offset = normalMap[placement].offset; - switch (placement[0]) { - case 'b': - case 't': - { - const plus = offset[0] > 0 ? 1 : -1; - result.style.left = result.style.left + (plus * width) / 2 - offset[0]; - } - break; - case 'l': - case 'r': - { - const plus = offset[0] > 0 ? 1 : -1; - result.style.top = result.style.top + (plus * height) / 2 - offset[1]; - } - break; - } - } - } - - return result; - }; - - render() { - const { - id, - type, - prefix, - className, - title, - alignEdge, - trigger, - triggerType, - children, - closable, - shouldUpdatePosition, - delay, - needAdjust, - autoAdjust, - safeId, - autoFocus, - safeNode, - onClick, - onHover, - animation, - offset, - style, - container, - popupContainer, - cache, - popupStyle, - popupClassName, - popupProps, - followTrigger, - rtl, - v2, - arrowPointToCenter, - placementOffset = 0, - ...others - } = this.props; - - if (container) { - log.deprecated('container', 'popupContainer', 'Balloon'); - } - - const { align } = this.state; - - alignMap = alignEdge || v2 ? edgeMap : normalMap; - const _prefix = this.context.prefix || prefix; - - let trOrigin = 'trOrigin'; - if (rtl) { - trOrigin = 'rtlTrOrigin'; - } - - const _offset = [alignMap[align].offset[0] + offset[0], alignMap[align].offset[1] + offset[1]]; - const transformOrigin = alignMap[align][trOrigin]; - const _style = { ...{ transformOrigin }, ...style }; - - const content = ( - - {children} - - ); - - const triggerProps = {}; - triggerProps['aria-describedby'] = id; - triggerProps.tabIndex = '0'; - - const ariaTrigger = id ? React.cloneElement(trigger, triggerProps) : trigger; - - const newTrigger = getDisabledCompatibleTrigger( - React.isValidElement(ariaTrigger) ? ariaTrigger : {ariaTrigger} - ); - - const otherProps = { - delay: delay, - shouldUpdatePosition: shouldUpdatePosition, - needAdjust: needAdjust, - align: alignMap[align].align, - offset: _offset, - safeId, - onHover, - onPosition: this._onPosition, - }; - - if (v2) { - delete otherProps.align; - delete otherProps.shouldUpdatePosition; - delete otherProps.needAdjust; - delete otherProps.safeId; - delete otherProps.onHover; - delete otherProps.onPosition; - - Object.assign(otherProps, { - placement: align, - placementOffset: placementOffset + 12, - v2: true, - beforePosition: this.beforePosition, - autoAdjust, - }); - } - - return ( - - {content} - - ); - } -} - -export default polyfill(Balloon); diff --git a/components/balloon/balloon.tsx b/components/balloon/balloon.tsx new file mode 100644 index 0000000000..defb1aa488 --- /dev/null +++ b/components/balloon/balloon.tsx @@ -0,0 +1,392 @@ +import React, { type ReactElement, type MouseEvent } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import Overlay from '../overlay'; +import { func, obj, log } from '../util'; +import BalloonInner from './inner'; +import { normalMap, edgeMap } from './alignMap'; +import { getDisabledCompatibleTrigger } from './util'; +import type { + AlignType, + BalloonProps, + BalloonV1Props, + BalloonV2Props, + BalloonState, +} from './types'; +import type { PopupProps } from '../overlay/types'; + +const { noop } = func; +const { Popup } = Overlay; + +const alignList = ['t', 'r', 'b', 'l', 'tl', 'tr', 'bl', 'br', 'lt', 'lb', 'rt', 'rb']; + +let alignMap = normalMap; + +class Balloon extends React.Component { + readonly props: BalloonV1Props & BalloonV2Props; + static displayName = 'Balloon'; + static contextTypes = { + prefix: PropTypes.string, + }; + static propTypes = { + prefix: PropTypes.string, + pure: PropTypes.bool, + rtl: PropTypes.bool, + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.any, + size: PropTypes.string, + type: PropTypes.oneOf(['normal', 'primary']), + title: PropTypes.node, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + alignEdge: PropTypes.bool, + v2: PropTypes.bool, + arrowPointToCenter: PropTypes.bool, + placementOffset: PropTypes.number, + closable: PropTypes.bool, + align: PropTypes.oneOf(alignList), + offset: PropTypes.array, + trigger: PropTypes.any, + triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + onClick: PropTypes.func, + onClose: PropTypes.func, + onHover: PropTypes.func, + autoAdjust: PropTypes.bool, + needAdjust: PropTypes.bool, + delay: PropTypes.number, + mouseEnterDelay: PropTypes.number, + mouseLeaveDelay: PropTypes.number, + afterClose: PropTypes.func, + shouldUpdatePosition: PropTypes.bool, + autoFocus: PropTypes.bool, + safeNode: PropTypes.string, + safeId: PropTypes.string, + animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + cache: PropTypes.bool, + popupContainer: PropTypes.any, + container: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + id: PropTypes.string, + }; + static defaultProps = { + prefix: 'next-', + pure: false, + type: 'normal', + closable: true, + defaultVisible: false, + size: 'medium', + alignEdge: false, + arrowPointToCenter: false, + align: 'b', + offset: [0, 0], + trigger: , + onClose: noop, + afterClose: noop, + onVisibleChange: noop, + needAdjust: false, + triggerType: 'hover', + safeNode: undefined, + safeId: null, + autoFocus: true, + animation: { + in: 'zoomIn zoomInBig', + out: 'zoomOut zoomOutBig', + }, + cache: false, + popupStyle: {}, + popupClassName: '', + popupProps: {}, + }; + + constructor(props: BalloonProps) { + super(props); + this.state = { + align: alignList.includes(props.align!) ? props.align : 'b', + visible: 'visible' in props ? props.visible : props.defaultVisible, + }; + this._onClose = this._onClose.bind(this); + this._onPosition = this._onPosition.bind(this); + this._onVisibleChange = this._onVisibleChange.bind(this); + } + + static getDerivedStateFromProps(nextProps: BalloonProps, prevState: BalloonState) { + const nextState: BalloonState = {}; + if ('visible' in nextProps) { + nextState.visible = nextProps.visible; + } + + if ( + !prevState.innerAlign && + 'align' in nextProps && + alignList.includes(nextProps.align!) && + nextProps.align !== prevState.align + ) { + nextState.align = nextProps.align; + nextState.innerAlign = false; + } + + return nextState; + } + + _onVisibleChange(visible: boolean, trigger: string) { + // Not Controlled + if (!('visible' in this.props)) { + this.setState({ + visible: visible, + }); + } + + this.props.onVisibleChange!(visible, trigger); + + if (!visible) { + this.props.onClose!(); + } + } + + _onClose(e: MouseEvent) { + this._onVisibleChange(false, 'closeClick'); + + //必须加上 preventDefault,否则单测 IE 下报错,出现 full page reload 异常 + e.preventDefault(); + } + + _onPosition(res: { + align?: string[]; + config: { placement: string; points: string }; + style?: CSSCounterStyleRule; + }) { + const { rtl } = this.props; + alignMap = this.props.alignEdge ? edgeMap : normalMap; + const newAlign = res.align!.join(' '); + let resAlign: AlignType; + + let alignKey: 'align' | 'rtlAlign' = 'align'; + if (rtl) { + alignKey = 'rtlAlign'; + } + + for (const key in alignMap) { + if (alignMap[key as AlignType][alignKey] === newAlign) { + resAlign = key as AlignType; + + break; + } + } + + // @ts-expect-error 在赋值前使用了变量 + resAlign = resAlign || this.state.align; + if (resAlign !== this.state.align) { + this.setState({ + align: resAlign, + innerAlign: true, + }); + } + } + + beforePosition = ( + result: { config: { placement: AlignType }; style: { left: number; top: number } }, + obj: { target: { width: number; height: number } } + ) => { + const { placement } = result.config; + if (placement !== this.state.align) { + this.setState({ + align: placement, + innerAlign: true, + }); + } + + if (this.props.arrowPointToCenter) { + const { width, height } = obj.target; + if (placement.length === 2) { + const offset = normalMap[placement].offset; + switch (placement[0]) { + case 'b': + case 't': + { + const plus = offset[0] > 0 ? 1 : -1; + result.style.left = result.style.left + (plus * width) / 2 - offset[0]; + } + break; + case 'l': + case 'r': + { + const plus = offset[0] > 0 ? 1 : -1; + result.style.top = result.style.top + (plus * height) / 2 - offset[1]; + } + break; + } + } + } + + return result; + }; + + render() { + const { + id, + type, + prefix, + className, + title, + alignEdge, + trigger, + triggerType, + children, + closable, + shouldUpdatePosition, + delay, + mouseEnterDelay, + mouseLeaveDelay, + needAdjust, + autoAdjust, + safeId, + autoFocus, + safeNode, + onClick, + onHover, + animation, + offset, + style, + container, + popupContainer, + cache, + popupStyle, + popupClassName, + popupProps, + followTrigger, + rtl, + v2, + arrowPointToCenter, + placementOffset = 0, + ...others + } = this.props; + + if (container) { + log.deprecated('container', 'popupContainer', 'Balloon'); + } + + const { align } = this.state; + + alignMap = alignEdge || v2 ? edgeMap : normalMap; + const _prefix = this.context.prefix || prefix; + + let trOrigin: 'trOrigin' | 'rtlTrOrigin' = 'trOrigin'; + if (rtl) { + trOrigin = 'rtlTrOrigin'; + } + + const _offset = [ + alignMap[align!].offset[0] + offset![0]!, + alignMap[align!].offset[1] + offset![1]!, + ]; + const transformOrigin = alignMap[align!][trOrigin]; + const _style = { ...{ transformOrigin }, ...style }; + + const content = ( + + {children} + + ); + + const triggerProps: { + 'aria-describedby'?: string; + tabIndex?: string; + } = {}; + triggerProps['aria-describedby'] = id; + triggerProps.tabIndex = '0'; + + const ariaTrigger = id + ? React.cloneElement(trigger as ReactElement, triggerProps) + : trigger; + + const newTrigger = getDisabledCompatibleTrigger( + React.isValidElement(ariaTrigger) ? ariaTrigger : {ariaTrigger} + ); + + const otherProps: { + delay?: number; + mouseEnterDelay?: number; + mouseLeaveDelay?: number; + shouldUpdatePosition?: boolean; + needAdjust?: boolean; + align?: string; + offset?: number[]; + safeId?: string; + onHover?: (visible: boolean, e: Event) => void; + onPosition?: PopupProps['onPosition']; + } = { + delay: delay, + mouseEnterDelay: mouseEnterDelay, + mouseLeaveDelay: mouseLeaveDelay, + shouldUpdatePosition: shouldUpdatePosition, + needAdjust: needAdjust, + align: alignMap[align!].align, + offset: _offset, + safeId, + onHover, + onPosition: this._onPosition, + }; + + if (v2) { + delete otherProps.align; + delete otherProps.shouldUpdatePosition; + delete otherProps.needAdjust; + delete otherProps.safeId; + delete otherProps.onHover; + delete otherProps.onPosition; + + Object.assign(otherProps, { + placement: align, + placementOffset: placementOffset, + v2: true, + beforePosition: this.beforePosition, + autoAdjust, + }); + } + + return ( + + {content} + + ); + } +} + +export default polyfill(Balloon); diff --git a/components/balloon/index.d.ts b/components/balloon/index.d.ts deleted file mode 100644 index f61e371981..0000000000 --- a/components/balloon/index.d.ts +++ /dev/null @@ -1,254 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; -import { PopupProps } from '../overlay'; - -export interface TooltipProps extends React.HTMLAttributes, CommonProps { - /** - * 样式类名的品牌前缀 - */ - prefix?: string; - - /** - * 自定义类名 - */ - className?: string; - - /** - * 自定义内联样式 - */ - style?: React.CSSProperties; - - /** - * tooltip的内容 - */ - children?: any; - - /** - * 弹出层位置 - */ - align?: 't' | 'r' | 'b' | 'l' | 'tl' | 'tr' | 'bl' | 'br' | 'lt' | 'lb' | 'rt' | 'rb'; - - /** - * 触发元素 - */ - trigger?: any; - - /** - * 触发行为 - * 鼠标悬浮, 鼠标点击('hover', 'click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用triggerType为click的Balloon组件 - */ - triggerType?: string | Array; - - /** - * 弹层组件style,透传给Popup - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层组件className,透传给Popup - */ - popupClassName?: string; - - /** - * 弹层组件属性,透传给Popup - */ - popupProps?: PopupProps; - - /** - * 弹层在触发以后的延时显示, 单位毫秒 ms - */ - delay?: number; - - /** - * 是否pure render - */ - pure?: boolean; - - /** - * 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层id, 传入值才会支持无障碍 - */ - id?: string; - followTrigger?: boolean; - /** - * 开启 v2 - */ - v2?: boolean; - /** - * [v2] 箭头是否指向目标元素的中心 - */ - arrowPointToCenter?: boolean; -} - -export class Tooltip extends React.Component {} - -interface HTMLAttributesWeak extends React.HTMLAttributes { - title?: any; -} -export interface BalloonProps extends HTMLAttributesWeak, CommonProps { - /** - * 自定义类名 - */ - className?: string; - - /** - * 自定义内敛样式 - */ - style?: React.CSSProperties; - - /** - * 浮层的内容 - */ - children?: any; - - title?: React.ReactNode; - - /** - * 样式类型 - */ - type?: 'normal' | 'primary'; - - /** - * 弹层当前显示的状态 - */ - visible?: boolean; - - /** - * 弹层默认显示的状态 - */ - defaultVisible?: boolean; - - /** - * 弹层在显示和隐藏触发的事件 - */ - onVisibleChange?: (visible: boolean, type: string) => void; - - /** - * 弹出层对齐方式 - */ - alignEdge?: boolean; - - /** - * 是否显示关闭按钮 - */ - closable?: boolean; - - /** - * 弹出层位置 - */ - align?: 't' | 'r' | 'b' | 'l' | 'tl' | 'tr' | 'bl' | 'br' | 'lt' | 'lb' | 'rt' | 'rb'; - - /** - * 弹层相对于trigger的定位的微调 - */ - offset?: Array; - - /** - * 触发元素 - */ - trigger?: any; - - /** - * 触发行为 - * 鼠标悬浮, 鼠标点击('hover','click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用click - */ - triggerType?: string | Array; - - /** - * 任何visible为false时会触发的事件 - */ - onClose?: () => void; - - /** - * 是否进行自动位置调整 - */ - needAdjust?: boolean; - - /** - * 弹层在触发以后的延时显示, 单位毫秒 ms - */ - delay?: number; - - /** - * 浮层关闭后触发的事件, 如果有动画,则在动画结束后触发 - */ - afterClose?: () => void; - - /** - * 强制更新定位信息 - */ - shouldUpdatePosition?: boolean; - - /** - * 弹层出现后是否自动focus到内部第一个元素 - */ - autoFocus?: boolean; - - /** - * 安全节点:对于triggetType为click的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode用于添加不触发关闭的节点, 值可以是dom节点的id或者是节点的dom对象 - */ - safeNode?: any; - - /** - * 用来指定safeNode节点的id,和safeNode配合使用 - */ - safeId?: string; - - /** - * 配置动画的播放方式 - */ - animation?: any | boolean; - - /** - * 弹层的dom节点关闭时是否删除 - */ - cache?: boolean; - - /** - * 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层组件style,透传给Popup - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层组件className,透传给Popup - */ - popupClassName?: string; - - /** - * 弹层组件属性,透传给Popup - */ - popupProps?: PopupProps; - - /** - * 弹层id, 传入值才会支持无障碍 - */ - id?: string; - followTrigger?: boolean; - /** - * 开启 v2 - */ - v2?: boolean; - /** - * [v2] 箭头是否指向目标元素的中心 - */ - arrowPointToCenter?: boolean; - /** - * [v2] 是否进行自动位置调整,默认自动开启 - */ - autoAdjust?: boolean; -} - -export default class Balloon extends React.Component { - static Tooltip: typeof Tooltip; -} diff --git a/components/balloon/index.jsx b/components/balloon/index.jsx deleted file mode 100644 index 14c7078811..0000000000 --- a/components/balloon/index.jsx +++ /dev/null @@ -1,42 +0,0 @@ -import ConfigProvider from '../config-provider'; -import Balloon from './balloon'; -import Tooltip from './tooltip'; -import Inner from './inner'; - -Balloon.Tooltip = ConfigProvider.config(Tooltip, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('text' in props) { - deprecated('text', 'children', 'Tooltip'); - const { text, ...others } = props; - props = { children: text, ...others }; - } - - return props; - }, -}); -Balloon.Inner = Inner; - -export default ConfigProvider.config(Balloon, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if (props.alignment) { - deprecated('alignment', 'alignEdge', 'Balloon'); - const { alignment, ...others } = props; - props = { alignEdge: alignment === 'edge', ...others }; - } - if (props.onCloseClick) { - deprecated('onCloseClick', 'onVisibleChange(visible, [type = "closeClick"])', 'Balloon'); - const { onCloseClick, onVisibleChange, ...others } = props; - const newOnVisibleChange = (visible, type) => { - if (type === 'closeClick') { - onCloseClick(); - } - if (onVisibleChange) { - onVisibleChange(visible, type); - } - }; - props = { onVisibleChange: newOnVisibleChange, ...others }; - } - - return props; - }, -}); diff --git a/components/balloon/index.tsx b/components/balloon/index.tsx new file mode 100644 index 0000000000..756ca68f98 --- /dev/null +++ b/components/balloon/index.tsx @@ -0,0 +1,58 @@ +import ConfigProvider from '../config-provider'; +import Balloon from './balloon'; +import Tooltip from './tooltip'; +import Inner from './inner'; +import { assignSubComponent } from '../util/component'; + +export type { + BalloonProps, + BalloonV1Props, + BalloonV2Props, + TooltipProps, + TooltipV1Props, + TooltipV2Props, + AlignType, +} from './types'; + +const BalloonWithSub = assignSubComponent(Balloon, { + Tooltip: ConfigProvider.config(Tooltip, { + transform: (props, deprecated) => { + if ('text' in props) { + deprecated('text', 'children', 'Tooltip'); + const { text, ...others } = props; + props = { children: text, ...others }; + } + + return props; + }, + }), + Inner, +}); +export default ConfigProvider.config(BalloonWithSub, { + transform: (props, deprecated) => { + if (props.alignment) { + deprecated('alignment', 'alignEdge', 'Balloon'); + const { alignment, ...others } = props; + props = { alignEdge: alignment === 'edge', ...others }; + } + if (props.onCloseClick) { + deprecated( + 'onCloseClick', + 'onVisibleChange(visible, [type = "closeClick"])', + 'Balloon' + ); + const { onCloseClick, onVisibleChange, ...others } = props; + const newOnVisibleChange = (visible: boolean, type: string) => { + if (type === 'closeClick') { + onCloseClick(); + } + if (onVisibleChange) { + onVisibleChange(visible, type); + } + }; + props = { onVisibleChange: newOnVisibleChange, ...others }; + } + + return props; + }, +}); diff --git a/components/balloon/inner.jsx b/components/balloon/inner.jsx deleted file mode 100644 index 61aebd042c..0000000000 --- a/components/balloon/inner.jsx +++ /dev/null @@ -1,124 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { func, obj } from '../util'; -import Icon from '../icon'; -import zhCN from '../locale/zh-cn'; -import { normalMap, edgeMap } from './alignMap'; - -/** - * Created by xiachi on 17/2/10. - */ - -const { noop } = func; - -class BalloonInner extends React.Component { - static contextTypes = { - prefix: PropTypes.string, - }; - static propTypes = { - prefix: PropTypes.string, - rtl: PropTypes.bool, - closable: PropTypes.bool, - children: PropTypes.any, - title: PropTypes.node, - className: PropTypes.string, - alignEdge: PropTypes.bool, - onClose: PropTypes.func, - style: PropTypes.any, - align: PropTypes.string, - type: PropTypes.string, - isTooltip: PropTypes.bool, - locale: PropTypes.object, - pure: PropTypes.bool, - v2: PropTypes.bool, - }; - static defaultProps = { - prefix: 'next-', - closable: true, - onClose: noop, - locale: zhCN.Balloon, - align: 'b', - type: 'normal', - alignEdge: false, - pure: false, - }; - - render() { - const { - prefix, - closable, - className, - style, - isTooltip, - align, - title, - type, - onClose, - alignEdge, - v2, - children, - rtl, - locale, - ...others - } = this.props; - - const alignMap = alignEdge || v2 ? edgeMap : normalMap; - let _prefix = prefix; - - if (isTooltip) { - _prefix = `${_prefix}balloon-tooltip`; - } else { - _prefix = `${_prefix}balloon`; - } - - const closableInTitle = closable && title !== undefined; - const closableInContent = closable && title === undefined; - - const classes = classNames({ - [`${_prefix}`]: true, - [`${_prefix}-${type}`]: type, - [`${_prefix}-medium`]: true, - [`${_prefix}-${alignMap[align].arrow}`]: alignMap[align], - [`${_prefix}-closable`]: closableInContent, - [className]: className, - [`${_prefix}-v2`]: v2, - }); - - const titleCls = classNames({ - [`${prefix}balloon-title`]: true, - [`${_prefix}-closable`]: closableInTitle, - }); - - const closeIcon = ( - - - - ); - - return ( -
-
-
-
- {title && ( -
- {title} - {closableInTitle && closeIcon} -
- )} -
{children}
- {closableInContent && closeIcon} -
- ); - } -} - -export default BalloonInner; diff --git a/components/balloon/inner.tsx b/components/balloon/inner.tsx new file mode 100644 index 0000000000..6df2701485 --- /dev/null +++ b/components/balloon/inner.tsx @@ -0,0 +1,130 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { func, obj } from '../util'; +import Icon from '../icon'; +import zhCN from '../locale/zh-cn'; +import { normalMap, edgeMap } from './alignMap'; +import type { BalloonInnerProps } from './types'; +/** + * Created by xiachi on 17/2/10. + */ + +const { noop } = func; + +class BalloonInner extends React.Component { + static contextTypes = { + prefix: PropTypes.string, + }; + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + closable: PropTypes.bool, + children: PropTypes.any, + title: PropTypes.node, + className: PropTypes.string, + alignEdge: PropTypes.bool, + onClose: PropTypes.func, + style: PropTypes.any, + align: PropTypes.string, + type: PropTypes.string, + isTooltip: PropTypes.bool, + locale: PropTypes.object, + pure: PropTypes.bool, + v2: PropTypes.bool, + }; + static defaultProps = { + prefix: 'next-', + closable: true, + onClose: noop, + locale: zhCN.Balloon, + align: 'b', + type: 'normal', + alignEdge: false, + pure: false, + }; + + render() { + const { + prefix, + closable, + className, + style, + isTooltip, + align, + title, + type, + onClose, + alignEdge, + v2, + children, + rtl, + locale, + ...others + } = this.props; + + const alignMap = alignEdge || v2 ? edgeMap : normalMap; + let _prefix = prefix; + + if (isTooltip) { + _prefix = `${_prefix}balloon-tooltip`; + } else { + _prefix = `${_prefix}balloon`; + } + + const closableInTitle = closable && title !== undefined; + const closableInContent = closable && title === undefined; + + const classes = classNames({ + [`${_prefix}`]: true, + [`${_prefix}-${type}`]: type, + [`${_prefix}-medium`]: true, + [`${_prefix}-${alignMap[align!].arrow}`]: alignMap[align!], + [`${_prefix}-closable`]: closableInContent, + [className!]: className, + [`${_prefix}-v2`]: v2, + }); + + const titleCls = classNames({ + [`${prefix}balloon-title`]: true, + [`${_prefix}-closable`]: closableInTitle, + }); + + const closeIcon = ( + + + + ); + + return ( +
+
+
+
+ {title && ( +
+ {title} + {closableInTitle && closeIcon} +
+ )} +
{children}
+ {closableInContent && closeIcon} +
+ ); + } +} + +export default BalloonInner; diff --git a/components/balloon/mobile/index.jsx b/components/balloon/mobile/index.jsx deleted file mode 100644 index 4bee41511f..0000000000 --- a/components/balloon/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Balloon as MeetBalloon } from '@alifd/meet-react'; -import NextBalloon from '../index'; - -const Balloon = MeetBalloon ? MeetBalloon : NextBalloon; - -export default Balloon; diff --git a/components/balloon/mobile/index.tsx b/components/balloon/mobile/index.tsx new file mode 100644 index 0000000000..1fd89a157b --- /dev/null +++ b/components/balloon/mobile/index.tsx @@ -0,0 +1,7 @@ +// @ts-expect-error 此处报错没有找到组件 +import { Balloon as MeetBalloon } from '@alifd/meet-react'; +import NextBalloon from '../index'; + +const Balloon = MeetBalloon ? MeetBalloon : NextBalloon; + +export default Balloon; diff --git a/components/balloon/style.js b/components/balloon/style.ts similarity index 100% rename from components/balloon/style.js rename to components/balloon/style.ts diff --git a/components/balloon/tooltip.jsx b/components/balloon/tooltip.jsx deleted file mode 100644 index 73bc0136d3..0000000000 --- a/components/balloon/tooltip.jsx +++ /dev/null @@ -1,265 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Overlay from '../overlay'; -import BalloonInner from './inner'; -import { normalMap, edgeMap } from './alignMap'; -import { getDisabledCompatibleTrigger } from './util'; - -const { Popup } = Overlay; - -let alignMap = normalMap; -/** Balloon.Tooltip */ -export default class Tooltip extends React.Component { - static propTypes = { - /** - * 样式类名的品牌前缀 - */ - prefix: PropTypes.string, - /** - * 自定义类名 - */ - className: PropTypes.string, - /** - * 自定义内联样式 - */ - style: PropTypes.object, - /** - * tooltip的内容 - */ - children: PropTypes.any, - /** - * 弹出层位置 - * @enumdesc 上, 右, 下, 左, 上左, 上右, 下左, 下右, 左上, 左下, 右上, 右下 - */ - align: PropTypes.oneOf(['t', 'r', 'b', 'l', 'tl', 'tr', 'bl', 'br', 'lt', 'lb', 'rt', 'rb']), - /** - * 触发元素 - */ - trigger: PropTypes.any, - /** - * 触发行为 - * 鼠标悬浮, 鼠标点击('hover', 'click')或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用triggerType为click的Balloon组件 - */ - triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - /** - * 弹层组件style,透传给Popup - */ - popupStyle: PropTypes.object, - /** - * 弹层组件className,透传给Popup - */ - popupClassName: PropTypes.string, - /** - * 弹层组件属性,透传给Popup - */ - popupProps: PropTypes.object, - /** - * 是否pure render - */ - pure: PropTypes.bool, - /** - * 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数。 - */ - popupContainer: PropTypes.any, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 弹层id, 传入值才会支持无障碍 - */ - id: PropTypes.string, - /** - * 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100 - */ - delay: PropTypes.number, - /** - * 开启 v2 版本 - */ - v2: PropTypes.bool, - /** - * [v2] 箭头是否指向目标元素的中心 - */ - arrowPointToCenter: PropTypes.bool, - }; - static defaultProps = { - triggerType: 'hover', - prefix: 'next-', - align: 'b', - delay: 50, - trigger: , - arrowPointToCenter: false, - }; - - constructor(props) { - super(props); - this.state = { - align: props.placement || props.align, - innerAlign: false, - }; - } - - static getDerivedStateFromProps(nextProps, prevState) { - if (nextProps.v2 && !prevState.innerAlign && 'align' in nextProps && nextProps.align !== prevState.align) { - return { - align: nextProps.align, - innerAlign: false, - }; - } - - return null; - } - - beforePosition = (result, obj) => { - const { placement } = result.config; - if (placement !== this.state.align) { - this.setState({ - align: placement, - innerAlign: true, - }); - } - - if (this.props.arrowPointToCenter) { - const { width, height } = obj.target; - if (placement.length === 2) { - const offset = normalMap[placement].offset; - switch (placement[0]) { - case 'b': - case 't': - { - const plus = offset[0] > 0 ? 1 : -1; - result.style.left = result.style.left + (plus * width) / 2 - offset[0]; - } - break; - case 'l': - case 'r': - { - const plus = offset[0] > 0 ? 1 : -1; - result.style.top = result.style.top + (plus * height) / 2 - offset[1]; - } - break; - } - } - } - - return result; - }; - - render() { - const { - id, - className, - align: palign, - style, - prefix, - trigger, - children, - popupContainer, - popupProps, - popupClassName, - popupStyle, - followTrigger, - triggerType, - autoFocus, - alignEdge, - autoAdjust, - rtl, - delay, - v2, - arrowPointToCenter, - ...others - } = this.props; - - let trOrigin = 'trOrigin'; - if (rtl) { - others.rtl = true; - trOrigin = 'rtlTrOrigin'; - } - - alignMap = alignEdge || v2 ? edgeMap : normalMap; - const align = v2 ? this.state.align : palign; - - const transformOrigin = alignMap[align][trOrigin]; - const _offset = alignMap[align].offset; - const _style = { transformOrigin, ...style }; - - const content = ( - - {children} - - ); - - const triggerProps = {}; - triggerProps['aria-describedby'] = id; - triggerProps.tabIndex = '0'; - - let newTriggerType = triggerType; - - if (triggerType === 'hover' && id) { - newTriggerType = ['focus', 'hover']; - } - - const ariaTrigger = id ? React.cloneElement(trigger, triggerProps) : trigger; - - const newTrigger = getDisabledCompatibleTrigger( - React.isValidElement(ariaTrigger) ? ariaTrigger : {ariaTrigger} - ); - - const otherProps = { - delay: delay, - shouldUpdatePosition: true, - needAdjust: false, - align: alignMap[align].align, - offset: _offset, - }; - - if (v2) { - delete otherProps.align; - delete otherProps.shouldUpdatePosition; - delete otherProps.needAdjust; - delete otherProps.offset; - - Object.assign(otherProps, { - placement: align, - placementOffset: 12, - v2: true, - beforePosition: this.beforePosition, - autoAdjust, - }); - } - - return ( - - {content} - - ); - } -} diff --git a/components/balloon/tooltip.tsx b/components/balloon/tooltip.tsx new file mode 100644 index 0000000000..68cf7b3ed1 --- /dev/null +++ b/components/balloon/tooltip.tsx @@ -0,0 +1,268 @@ +import React, { Component, type ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import Overlay from '../overlay'; +import BalloonInner from './inner'; +import { normalMap, edgeMap } from './alignMap'; +import { getDisabledCompatibleTrigger } from './util'; +import type { + AlignType, + TooltipProps, + TooltipV1Props, + TooltipV2Props, + TooltipState, +} from './types'; + +const { Popup } = Overlay; + +let alignMap = normalMap; +/** Balloon.Tooltip */ +export default class Tooltip extends Component { + static displayName = 'Tooltip'; + + static propTypes = { + prefix: PropTypes.string, + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.any, + align: PropTypes.oneOf([ + 't', + 'r', + 'b', + 'l', + 'tl', + 'tr', + 'bl', + 'br', + 'lt', + 'lb', + 'rt', + 'rb', + ]), + trigger: PropTypes.any, + triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + pure: PropTypes.bool, + popupContainer: PropTypes.any, + followTrigger: PropTypes.bool, + id: PropTypes.string, + delay: PropTypes.number, + mouseEnterDelay: PropTypes.number, + mouseLeaveDelay: PropTypes.number, + v2: PropTypes.bool, + arrowPointToCenter: PropTypes.bool, + }; + + static defaultProps = { + triggerType: 'hover', + prefix: 'next-', + align: 'b', + delay: 50, + trigger: , + arrowPointToCenter: false, + }; + + readonly props: TooltipV1Props & TooltipV2Props; + + constructor(props: TooltipProps) { + super(props); + this.state = { + align: props.placement || props.align, + innerAlign: false, + }; + } + + static getDerivedStateFromProps(nextProps: TooltipProps, prevState: TooltipState) { + if ( + nextProps.v2 && + !prevState.innerAlign && + 'align' in nextProps && + nextProps.align !== prevState.align + ) { + return { + align: nextProps.align, + innerAlign: false, + }; + } + + return null; + } + + beforePosition = ( + result: { + config: { placement: AlignType }; + style: { left: number; top: number }; + }, + obj: { target: { width: number; height: number } } + ) => { + const { placement } = result.config; + if (placement !== this.state.align) { + this.setState({ + align: placement, + innerAlign: true, + }); + } + + if (this.props.arrowPointToCenter) { + const { width, height } = obj.target; + if (placement.length === 2) { + const offset = normalMap[placement].offset; + switch (placement[0]) { + case 'b': + case 't': + { + const plus = offset[0] > 0 ? 1 : -1; + result.style.left = result.style.left + (plus * width) / 2 - offset[0]; + } + break; + case 'l': + case 'r': + { + const plus = offset[0] > 0 ? 1 : -1; + result.style.top = result.style.top + (plus * height) / 2 - offset[1]; + } + break; + } + } + } + + return result; + }; + + render() { + const { + id, + className, + align: palign, + style, + prefix, + trigger, + children, + popupContainer, + popupProps, + popupClassName, + popupStyle, + followTrigger, + triggerType, + autoFocus, + alignEdge, + autoAdjust, + rtl, + delay, + mouseEnterDelay, + mouseLeaveDelay, + v2, + arrowPointToCenter, + ...others + } = this.props; + + let trOrigin: 'trOrigin' | 'rtlTrOrigin' = 'trOrigin'; + if (rtl) { + // @ts-expect-error others 上没有 rtl 属性 + others.rtl = true; + trOrigin = 'rtlTrOrigin'; + } + + alignMap = alignEdge || v2 ? edgeMap : normalMap; + const align = v2 ? this.state.align : palign; + + const transformOrigin = alignMap[align!][trOrigin]; + const _offset = alignMap[align!].offset; + const _style = { transformOrigin, ...style }; + + const content = ( + + {children} + + ); + + const triggerProps: { + 'aria-describedby'?: string; + tabIndex?: string; + } = {}; + triggerProps['aria-describedby'] = id; + triggerProps.tabIndex = '0'; + + let newTriggerType = triggerType; + + if (triggerType === 'hover' && id) { + newTriggerType = ['focus', 'hover']; + } + + const ariaTrigger = id + ? React.cloneElement(trigger as ReactElement, triggerProps) + : trigger; + + const newTrigger = getDisabledCompatibleTrigger( + React.isValidElement(ariaTrigger) ? ariaTrigger : {ariaTrigger} + ); + + const otherProps: { + delay?: number; + mouseEnterDelay?: number; + mouseLeaveDelay?: number; + shouldUpdatePosition?: boolean; + needAdjust?: boolean; + align?: string; + offset?: number[]; + } = { + delay: delay, + mouseEnterDelay: mouseEnterDelay, + mouseLeaveDelay: mouseLeaveDelay, + shouldUpdatePosition: true, + needAdjust: false, + align: alignMap[align!].align, + offset: _offset, + }; + + if (v2) { + delete otherProps.align; + delete otherProps.shouldUpdatePosition; + delete otherProps.needAdjust; + delete otherProps.offset; + + Object.assign(otherProps, { + placement: align, + placementOffset: 12, + v2: true, + beforePosition: this.beforePosition, + autoAdjust, + }); + } + + return ( + + {content} + + ); + } +} diff --git a/components/balloon/types.ts b/components/balloon/types.ts new file mode 100644 index 0000000000..b0d7cc1812 --- /dev/null +++ b/components/balloon/types.ts @@ -0,0 +1,963 @@ +import type { + ComponentPropsWithRef, + CSSProperties, + MouseEventHandler, + ReactElement, + ReactNode, +} from 'react'; +import type { CommonProps } from '../util'; +import Overlay, { type PopupProps } from '../overlay'; + +import type { Locale } from '../locale/types'; + +const { Popup } = Overlay; +interface HTMLAttributesWeak extends Omit, 'title'> {} + +/** + * @api Balloon V2 + * @order 0 + */ +export interface BalloonV2Props extends HTMLAttributesWeak, CommonProps { + /** + * 开启 v2 版本 + * @en Enable v2 + * @version 1.25 + */ + v2?: true; + /** + * 是否 pure render + * @en Whether to pure render + * @skip + */ + pure?: boolean; + + /** + * 是否开启 rtl + * @en Whether to enable rtl + * @skip + */ + rtl?: boolean; + + /** + * 自定义内联样式 + * @en Custom inline style + * @skip + */ + style?: CSSProperties; + + /** + * 浮层的内容 + * @en Content of popup + */ + children?: ReactNode; + + /** + * 弹层的尺寸 + * @en Size of popup + * @skip + */ + size?: string; + + /** + * 样式类型 + * @en Style type + * @version 1.23 + * @defaultValue 'normal' + */ + type?: 'normal' | 'primary'; + + /** + * 标题 + * @en Title + * @version 1.23 + */ + title?: ReactNode; + + /** + * 弹层当前显示的状态 + * @en Popup current display status + */ + visible?: boolean; + + /** + * 弹层默认显示的状态 + * @en Popup default display status + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 弹层关闭时触发的事件 + * @en Popup close event + * @skip + * @deprecated use onVisibleChange instead + */ + onCloseClick?: () => void; + + /** + * 弹层在显示和隐藏触发的事件 + * @en Popup display and hide event + * @param visible - 弹层是否隐藏和显示 - wether the popup is hidden or displayed + * @param type - 触发弹层显示或隐藏的来源,closeClick 表示由自带的关闭按钮触发;fromTrigger 表示由 trigger 的点击触发;docClick 表示由 document 的点击触发 - source of trigger popup display or hide, closeClick means triggered by the close button; fromTrigger means triggered by the trigger click; docClick means triggered by the document click + */ + onVisibleChange?: (visible: boolean, type: string) => void; + + /** + * [v2] 箭头是否指向目标元素的中心 + * @en Whether the arrow points to the center of the target element + * @version 1.25 + * @defaultValue false + */ + arrowPointToCenter?: boolean; + + /** + * [v2] 弹层偏离触发元素的像素值 + * @en Popup offset + */ + placementOffset?: number; + + /** + * 弹出层对齐方式 + * @en Popup alignment + * @skip + */ + alignEdge?: boolean; + + /** + * 是否显示关闭按钮 + * @en Whether to display close button + * @defaultValue true + */ + closable?: boolean; + + /** + * 弹出层位置 + * @en Position of popup + * @defaultValue 'b' + */ + align?: AlignType; + + /** + * 弹出层位置 + * @en Position of popup + * @skip + * @deprecated use alignEdge instead + */ + alignment?: string; + + /** + * 弹层相对于 trigger 的定位的微调,接收数组 [hoz, ver], 表示弹层在 left / top 上的增量,e.g. [100, 100] 表示往右 (RTL 模式下是往左) 、下分布偏移 100px + * @en Tuning of popup relative to trigger, receive an array [hoz, ver], indicating the offset of the popup on left / top, e.g. [100, 100] means to the right (in RTL mode, it is to the left) and downward offset 100px + * @defaultValue [0, 0] + */ + offset?: Array; + + /** + * 触发元素 + * @en Trigger element + * @defaultValue + */ + trigger?: ReactElement | string; + + /** + * 触发行为,鼠标悬浮,鼠标点击 ('hover','click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用 click + * @en Trigger behavior, mouse hover, mouse click ('hover','click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use click + * @defaultValue 'hover' + */ + triggerType?: 'hover' | 'click' | 'focus' | ('hover' | 'click' | 'focus')[]; + + /** + * 点击事件 + * @en Click event + * @skip + */ + onClick?: () => void; + + /** + * hover 事件 + * @en hover event + * @skip + */ + onHover?: () => void; + + /** + * 任何 visible 为 false 时会触发的事件 + * @en Any event triggered when visible is false + */ + onClose?: () => void; + + /** + * [v2] 是否进行自动位置调整,默认自动开启 + * @en Whether to perform automatic position adjustment, default automatic opening + * @version 1.25 + */ + autoAdjust?: boolean; + + /** + * 是否进行自动位置调整 + * @en Whether to perform automatic position adjustment + * @skip + */ + needAdjust?: boolean; + + /** + * 弹层在触发以后的延时显示,单位毫秒 ms + * @en Popup delay display + */ + delay?: number; + + /** + * 鼠标放置后的延时显示,单位毫秒 ms + * @en Mouse delay display + * @skip + */ + mouseEnterDelay?: number; + + /** + * 鼠标离开后的延时显示,单位毫秒 ms + * @en Mouse delay display + * @skip + */ + mouseLeaveDelay?: number; + + /** + * 浮层关闭后触发的事件,如果有动画,则在动画结束后触发 + * @en Popup close event + */ + afterClose?: () => void; + + /** + * 强制更新定位信息 + * @en Force update location information + * @skip + */ + shouldUpdatePosition?: boolean; + + /** + * 弹层出现后是否自动 focus 到内部第一个元素 + * @en Whether to automatically focus to the internal first element + * @defaultValue true + */ + autoFocus?: boolean; + + /** + * 安全节点:对于 triggetType 为 click 的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode 用于添加不触发关闭的节点,值可以是 dom 节点的 id 或者是节点的 dom 对象 + * @en Safe node: for the popup with triggerType set to click, the popup will be closed when clicking on other areas other than the popup + */ + safeNode?: string | ReactNode; + + /** + * 用来指定 safeNode 节点的 id,和 safeNode 配合使用 + * @en Used to specify the id of the safeNode node, and combined with safeNode + * @defaultValue null + */ + safeId?: string; + + /** + * 配置动画的播放方式,格式是 \{ in: '', out: '' \},常用的动画 class 请查看 Animate 组件文档 + * @en Configure the playback method of the animation, the format is \{ in: '', out: '' \}, commonly used animation class please see the documentation of the Animate component + * @param in - 进场动画 + * @param out - 出场动画 + * @defaultValue \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} + */ + animation?: string | false | Record<'in' | 'out', string>; + + /** + * 弹层的 dom 节点关闭时是否删除 + * @en Whether to delete the dom node of the popup when it is closed + * @defaultValue false + */ + cache?: boolean; + + /** + * 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 + * @en Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node + */ + popupContainer?: PopupProps['container']; + + /** + * 容器 + * @en Container + * @skip + */ + container?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); + + /** + * 弹层组件 style,透传给 Popup + * @en Popup style + */ + popupStyle?: CSSProperties; + + /** + * 弹层组件 className,透传给 Popup + * @en Popup className + */ + popupClassName?: string; + + /** + * 弹层组件属性,透传给 Popup + * @en Popup props + */ + popupProps?: ComponentPropsWithRef; + + /** + * 跟随滚动 + * @en Follow scrolling + */ + followTrigger?: boolean; + + /** + * 弹层 id, 传入值才会支持无障碍 + * @en Popup id, if passed value will support accessibility + */ + id?: string; +} +export type BalloonProps = BalloonV1Props | BalloonV2Props; +/** + * @api Balloon V1 + * @order 1 + */ +export interface BalloonV1Props extends HTMLAttributesWeak, CommonProps { + /** + * 开启 v2 版本 + * @en Enable v2 + * @version 1.25 + */ + v2?: false | undefined; + /** + * 是否 pure render + * @en Whether to pure render + * @skip + */ + pure?: boolean; + + /** + * 是否开启 rtl + * @en Whether to enable rtl + * @skip + */ + rtl?: boolean; + + /** + * 自定义内联样式 + * @en Custom inline style + * @skip + */ + style?: CSSProperties; + + /** + * 浮层的内容 + * @en Content of popup + */ + children?: ReactNode; + + /** + * 标题 + * @en Title + * @version 1.23 + */ + title?: ReactNode; + + /** + * 弹层的尺寸 + * @en Size of popup + * @skip + */ + size?: string; + + /** + * 样式类型 + * @en Style type + * @version 1.23 + * @defaultValue 'normal' + */ + type?: 'normal' | 'primary'; + + /** + * 弹层当前显示的状态 + * @en Popup current display status + */ + visible?: boolean; + + /** + * 弹层默认显示的状态 + * @en Popup default display status + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 弹层关闭时触发的事件 + * @en Popup close event + * @skip + * @deprecated use onVisibleChange instead + */ + onCloseClick?: () => void; + + /** + * 弹层在显示和隐藏触发的事件 + * @en Popup display and hide event + * @param visible - 弹层是否隐藏和显示 - wether the popup is hidden or displayed + * @param type - 触发弹层显示或隐藏的来源,closeClick 表示由自带的关闭按钮触发;fromTrigger 表示由 trigger 的点击触发;docClick 表示由 document 的点击触发 - source of trigger popup display or hide, closeClick means triggered by the close button; fromTrigger means triggered by the trigger click; docClick means triggered by the document click + */ + onVisibleChange?: (visible: boolean, type: string) => void; + + /** + * 弹出层对齐方式 + * @en Popup alignment + * @skip + */ + alignEdge?: boolean; + + /** + * 是否显示关闭按钮 + * @en Whether to display close button + * @defaultValue true + */ + closable?: boolean; + + /** + * 弹出层位置 + * @en Position of popup + * @defaultValue 'b' + */ + align?: AlignType; + + /** + * 弹出层位置 + * @en Position of popup + * @skip + * @deprecated use alignEdge instead + */ + alignment?: string; + + /** + * 弹层相对于 trigger 的定位的微调,接收数组 [hoz, ver], 表示弹层在 left / top 上的增量,e.g. [100, 100] 表示往右 (RTL 模式下是往左) 、下分布偏移 100px + * @en Tuning of popup relative to trigger, receive an array [hoz, ver], indicating the offset of the popup on left / top, e.g. [100, 100] means to the right (in RTL mode, it is to the left) and downward offset 100px + * @defaultValue [0, 0] + */ + offset?: Array; + + /** + * 触发元素 + * @en Trigger element + * @defaultValue + */ + trigger?: ReactElement | string; + + /** + * 触发行为,鼠标悬浮,鼠标点击 ('hover','click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若弹窗内容有复杂交互请使用 click + * @en Trigger behavior, mouse hover, mouse click ('hover','click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use click + * @defaultValue 'hover' + */ + triggerType?: 'hover' | 'click' | 'focus' | ('hover' | 'click' | 'focus')[]; + + /** + * 点击事件 + * @en Click event + * @skip + */ + onClick?: () => void; + + /** + * hover 事件 + * @en hover event + * @skip + */ + onHover?: () => void; + + /** + * 任何 visible 为 false 时会触发的事件 + * @en Any event triggered when visible is false + */ + onClose?: () => void; + + /** + * 是否进行自动位置调整 + * @en Whether to perform automatic position adjustment + * @skip + */ + needAdjust?: boolean; + + /** + * 弹层在触发以后的延时显示,单位毫秒 ms + * @en Popup delay display + */ + delay?: number; + + /** + * 鼠标放置后的延时显示,单位毫秒 ms + * @en Mouse delay display + * @skip + */ + mouseEnterDelay?: number; + + /** + * 鼠标离开后的延时显示,单位毫秒 ms + * @en Mouse delay display + * @skip + */ + mouseLeaveDelay?: number; + + /** + * 浮层关闭后触发的事件,如果有动画,则在动画结束后触发 + * @en Popup close event + */ + afterClose?: () => void; + + /** + * 强制更新定位信息 + * @en Force update location information + * @skip + */ + shouldUpdatePosition?: boolean; + + /** + * 弹层出现后是否自动 focus 到内部第一个元素 + * @en Whether to automatically focus to the internal first element + * @defaultValue true + */ + autoFocus?: boolean; + + /** + * 安全节点:对于 triggetType 为 click 的浮层,会在点击除了浮层外的其它区域时关闭浮层.safeNode 用于添加不触发关闭的节点,值可以是 dom 节点的 id 或者是节点的 dom 对象 + * @en Safe node: for the popup with triggerType set to click, the popup will be closed when clicking on other areas other than the popup + */ + safeNode?: string | ReactNode; + + /** + * 用来指定 safeNode 节点的 id,和 safeNode 配合使用 + * @en Used to specify the id of the safeNode node, and combined with safeNode + * @defaultValue null + */ + safeId?: string; + + /** + * 配置动画的播放方式,格式是 \{ in: '', out: '' \},常用的动画 class 请查看 Animate 组件文档 + * @en Configure the playback method of the animation, the format is \{ in: '', out: '' \}, commonly used animation class please see the documentation of the Animate component + * @param in - 进场动画 + * @param out - 出场动画 + * @defaultValue \{ in: 'zoomIn zoomInBig', out: 'zoomOut zoomOutBig', \} + */ + animation?: string | false | Record<'in' | 'out', string>; + + /** + * 弹层的 dom 节点关闭时是否删除 + * @en Whether to delete the dom node of the popup when it is closed + * @defaultValue false + */ + cache?: boolean; + + /** + * 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 + * @en Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node + */ + popupContainer?: PopupProps['container']; + + /** + * 容器 + * @en Container + * @skip + */ + container?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); + + /** + * 弹层组件 style,透传给 Popup + * @en Popup style + */ + popupStyle?: CSSProperties; + + /** + * 弹层组件 className,透传给 Popup + * @en Popup className + */ + popupClassName?: string; + + /** + * 弹层组件属性,透传给 Popup + * @en Popup props + */ + popupProps?: ComponentPropsWithRef; + + /** + * 跟随滚动 + * @en Follow scrolling + */ + followTrigger?: boolean; + + /** + * 弹层 id, 传入值才会支持无障碍 + * @en Popup id, if passed value will support accessibility + */ + id?: string; +} + +/** + * @api Balloon.Tooltip V2 + * @order 2 + */ +export interface TooltipV2Props extends HTMLAttributesWeak, CommonProps { + /** + * 开启 v2 + * @en Enable v2 + */ + v2?: true; + + /** + * 自定义内联样式 + * @en Custom inline style + * @skip + */ + style?: CSSProperties; + + /** + * tooltip 的内容 + * @en Content of tooltip + */ + children?: ReactNode; + + /** + * 弹出层位置 + * @en Position of popup + * @defaultValue 'b' + */ + align?: AlignType; + + /** + * 弹出层位置 + * @en Position of popup + * @skip + * @deprecated use align instead + */ + placement?: AlignType; + + /** + * 触发元素 + * @en Trigger element + * @defaultValue + */ + trigger?: ReactElement | string; + + /** + * 触发行为,鼠标悬浮,鼠标点击 ('hover', 'click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用 triggerType 为 click 的 Balloon 组件 + * @en Trigger behavior, mouse hover, mouse click ('hover', 'click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use the Balloon component with triggerType set to click + * @defaultValue 'hover' + */ + triggerType?: 'hover' | 'click' | 'focus' | ('hover' | 'click' | 'focus')[]; + + /** + * 弹层组件 style,透传给 Popup + * @en Popup style + */ + popupStyle?: CSSProperties; + + /** + * 弹层组件 className,透传给 Popup + * @en Popup className + */ + popupClassName?: string; + + /** + * 弹层组件属性,透传给 Popup + * @en Popup props + */ + popupProps?: ComponentPropsWithRef; + + /** + * 是否 pure render + * @en Whether to pure render + */ + pure?: boolean; + + /** + * 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 + * @en Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node + */ + popupContainer?: PopupProps['container']; + + /** + * 是否跟随滚动 + * @en Whether to follow scrolling + */ + followTrigger?: boolean; + + /** + * 弹层 id, 传入值才会支持无障碍 + * @en Popup id, if passed value will support accessibility + */ + id?: string; + + /** + * 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100px + * @en If needed, set this parameter to allow the Tooltip content to be clicked, e.g. 100px + * @defaultValue 50 + */ + delay?: number; + + /** + * 鼠标放置后的延时显示,单位毫秒 ms + * @en Delay display after mouse + * @skip + */ + mouseEnterDelay?: number; + + /** + * 鼠标离开后的延时显示,单位毫秒 ms + * @en Delay display after mouse + * @skip + */ + mouseLeaveDelay?: number; + + /** + * 是否自动 focus + * @en Whether to automatically focus + * @skip + */ + autoFocus?: boolean; + + /** + * 弹出层对齐方式 + * @en Popup alignment + * @skip + */ + alignEdge?: boolean; + + /** + * 是否自动调整 + * @en Whether to automatically adjust + * @skip + */ + autoAdjust?: boolean; + + /** + * 是否开启 rtl + * @en Whether to enable rtl + * @skip + */ + rtl?: boolean; + + /** + * 弹出层是否显示 + * @en Popup is displayed + * @skip + */ + visible?: boolean; + + /** + * 组件内容 + * @en Component content + * @deprecated Use children instead + * @skip + */ + text?: ReactNode; + + /** + * [v2] 箭头是否指向目标元素的中心 + * @en Whether the arrow points to the center of the target element + * @defaultValue false + */ + arrowPointToCenter?: boolean; +} + +/** + * @api Balloon.Tooltip V1 + * @order 3 + */ +export interface TooltipV1Props extends HTMLAttributesWeak, CommonProps { + /** + * 开启 v2 + * @en Enable v2 + */ + v2?: false | undefined; + + /** + * 自定义内联样式 + * @en Custom inline style + * @skip + */ + style?: CSSProperties; + + /** + * tooltip 的内容 + * @en Content of tooltip + */ + children?: ReactNode; + + /** + * 弹出层位置 + * @en Position of popup + * @defaultValue 'b' + */ + align?: AlignType; + + /** + * 弹出层位置 + * @en Position of popup + * @skip + * @deprecated use align instead + */ + placement?: AlignType; + + /** + * 触发元素 + * @en Trigger element + * @defaultValue + */ + trigger?: ReactElement | string; + + /** + * 触发行为,鼠标悬浮,鼠标点击 ('hover', 'click') 或者它们组成的数组,如 ['hover', 'click'], 强烈不建议使用'focus',若有复杂交互,推荐使用 triggerType 为 click 的 Balloon 组件 + * @en Trigger behavior, mouse hover, mouse click ('hover', 'click') or an array of them, e.g. ['hover', 'click'], strongly not recommended to use 'focus', if the popup content has complex interactions, it is recommended to use the Balloon component with triggerType set to click + * @defaultValue 'hover' + */ + triggerType?: 'hover' | 'click' | 'focus' | ('hover' | 'click' | 'focus')[]; + + /** + * 弹层组件 style,透传给 Popup + * @en Popup style + */ + popupStyle?: CSSProperties; + + /** + * 弹层组件 className,透传给 Popup + * @en Popup className + */ + popupClassName?: string; + + /** + * 弹层组件属性,透传给 Popup + * @en Popup props + */ + popupProps?: ComponentPropsWithRef; + + /** + * 是否 pure render + * @en Whether to pure render + */ + pure?: boolean; + + /** + * 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数。 + * @en Specify the parent node of the floating layer that is rendered, which can be a string of node id, or a function that returns a node + */ + popupContainer?: PopupProps['container']; + + /** + * 是否跟随滚动 + * @en Whether to follow scrolling + */ + followTrigger?: boolean; + + /** + * 弹层 id, 传入值才会支持无障碍 + * @en Popup id, if passed value will support accessibility + */ + id?: string; + + /** + * 如果需要让 Tooltip 内容可被点击,可以设置这个参数,例如 100px + * @en If needed, set this parameter to allow the Tooltip content to be clicked, e.g. 100px + * @defaultValue 50 + */ + delay?: number; + + /** + * 鼠标放置后的延时显示,单位毫秒 ms + * @en Delay display after mouse + * @skip + */ + mouseEnterDelay?: number; + + /** + * 鼠标离开后的延时显示,单位毫秒 ms + * @en Delay display after mouse + * @skip + */ + mouseLeaveDelay?: number; + + /** + * 是否自动 focus + * @en Whether to automatically focus + * @skip + */ + autoFocus?: boolean; + + /** + * 弹出层对齐方式 + * @en Popup alignment + * @skip + */ + alignEdge?: boolean; + + /** + * 是否自动调整 + * @en Whether to automatically adjust + * @skip + */ + autoAdjust?: boolean; + + /** + * 是否开启 rtl + * @en Whether to enable rtl + * @skip + */ + rtl?: boolean; + + /** + * 弹出层是否显示 + * @en Popup is displayed + * @skip + */ + visible?: boolean; + + /** + * 组件内容 + * @en Component content + * @deprecated Use children instead + * @skip + */ + text?: ReactNode; +} +export type TooltipProps = TooltipV1Props | TooltipV2Props; +export interface TooltipState { + align?: AlignType; + innerAlign: boolean; +} + +export type AlignType = + | 't' + | 'r' + | 'b' + | 'l' + | 'tl' + | 'tr' + | 'bl' + | 'br' + | 'lt' + | 'lb' + | 'rt' + | 'rb'; + +export interface BalloonState { + visible?: boolean; + align?: AlignType; + innerAlign?: boolean; +} + +export interface BalloonInnerProps extends HTMLAttributesWeak, CommonProps { + rtl?: boolean; + closable?: boolean; + children?: ReactNode; + title?: ReactNode; + alignEdge: boolean; + onClose: MouseEventHandler; + style?: CSSProperties; + align: AlignType; + type: string; + isTooltip?: boolean; + pure: boolean; + v2?: boolean; + id?: string; + locale: Locale['Balloon']; + text?: ReactNode; +} diff --git a/components/balloon/util.jsx b/components/balloon/util.jsx deleted file mode 100644 index da4234ad11..0000000000 --- a/components/balloon/util.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; - -export function getDisabledCompatibleTrigger(element) { - if (element.type.displayName === 'Config(Button)' && element.props.disabled) { - const displayStyle = - element.props.style && element.props.style.display ? element.props.style.display : 'inline-block'; - const child = React.cloneElement(element, { - style: { - ...element.props.style, - pointerEvents: 'none', - }, - }); - return ( - // eslint-disable-next-line - {child} - ); - } - return element; -} diff --git a/components/balloon/util.tsx b/components/balloon/util.tsx new file mode 100644 index 0000000000..5a30446ff4 --- /dev/null +++ b/components/balloon/util.tsx @@ -0,0 +1,23 @@ +import React, { type ReactElement } from 'react'; + +export function getDisabledCompatibleTrigger( + element: ReactElement & { type: { displayName: string } } +) { + if (element.type.displayName === 'Config(Button)' && element.props.disabled) { + const displayStyle = + element.props.style && element.props.style.display + ? element.props.style.display + : 'inline-block'; + const child = React.cloneElement(element, { + style: { + ...element.props.style, + pointerEvents: 'none', + }, + }); + return ( + // eslint-disable-next-line + {child} + ); + } + return element; +} diff --git a/components/box/__docs__/demo/wrap/index.tsx b/components/box/__docs__/demo/wrap/index.tsx index 0282972532..87eaf02363 100644 --- a/components/box/__docs__/demo/wrap/index.tsx +++ b/components/box/__docs__/demo/wrap/index.tsx @@ -6,7 +6,7 @@ class BoxDemo extends React.Component { state = { wrap: true, }; - onSwitchChange = checked => { + onSwitchChange = (checked: boolean) => { this.setState({ wrap: checked, }); diff --git a/components/box/__docs__/index.en-us.md b/components/box/__docs__/index.en-us.md index 4ff55861c9..a1dafde486 100644 --- a/components/box/__docs__/index.en-us.md +++ b/components/box/__docs__/index.en-us.md @@ -10,6 +10,7 @@ ## Develop Guide ### When to Use + Flex box, added in 1.19.0+, support IE10+ `display: flex` of IE docs: https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/dev-guides/hh673531(v=vs.85) @@ -17,15 +18,24 @@ Flex box, added in 1.19.0+, support IE10+ ### Box -| Param | Description | Type | Default Value | -| --------- | -------------------------------------------------------------------------------- | ------------------------------ | ------ | -| flex | flex | Array/Number | - | -| direction | direction, column by default

**options**:
'row', 'column', 'row-reverse' | Enum | column | -| wrap | wrap or not, support IE11+ | Boolean | false | -| spacing | spaceing of element [bottom&top, right&left] | Array/Number | - | -| margin | css margin [bottom&top, right&left] | Array/Number | - | -| padding | css padding [bottom&top, right&left] | Array/Number | - | -| device | device for responsive

**options**:
'phone'(手机)
'tablet'(平板)
'desktop'(PC) | Enum | - | -| justify | justify-content

**options**:
'flex-start', 'center', 'flex-end', 'space-between', 'space-around' | Enum | - | -| align | align-items

**options**:
'flex-start', 'center', 'flex-end', 'baseline', 'stretch' | Enum | - | -| component | change the html tag, for example section | String | 'div' | +| Param | Description | Type | Default Value | Required | +| --------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------- | -------- | +| flex | Same as css attribute `flex`, support array mode setting | \| CSS.Property.Flex
\| [CSS.Property.FlexGrow, CSS.Property.FlexShrink, CSS.Property.FlexBasis] | - | | +| direction | Layout direction, same as css attribute `flex | CSS.Property.FlexDirection | 'column' | | +| wrap | Wrap or not | boolean | false | | +| spacing | Element spacing | Spacing | - | | +| margin | Container outer spacing | Spacing | - | | +| padding | Container inner spacing | Spacing | - | | +| justify | The alignment of items on the main axis, same as css attribute `justify | CSS.Property.JustifyContent | - | | +| align | The alignment of items on the cross axis, same as css attribute `align | CSS.Property.AlignItems | - | | +| component | Custom JSX tag name | keyof React.JSX.IntrinsicElements | 'div' | | + +### Spacing + +```typescript +export type Spacing = + | number + | [topAndBottom: number, rightAndLeft: number] + | [top: number, rightAndLeft: number, bottom: number] + | [top: number, right: number, bottom: number, left: number]; +``` diff --git a/components/box/__docs__/index.md b/components/box/__docs__/index.md index bc1a3eb0c3..def7bbda01 100644 --- a/components/box/__docs__/index.md +++ b/components/box/__docs__/index.md @@ -13,7 +13,7 @@ ## 何时使用 - 用于弹性布局, 通过`display: flex`实现。 -- 受浏览器限制,本功能支持到IE10+,IE下[#参考文档](https://docs.microsoft.com/en-us/previous-versions/windows/internet-explorer/ie-developer/dev-guides/hh673531(v=vs.85>))。 +- 受浏览器限制,本功能支持到IE10+,IE下[#参考文档]()。 ## FAQ @@ -23,13 +23,13 @@ ```jsx // wrong -function Foo () { - return
; +function Foo() { + return
; } // correct -function Foo ({style}) { - return
; +function Foo({ style }) { + return
; } ``` @@ -37,14 +37,24 @@ function Foo ({style}) { ### Box -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------ | ------ | -| flex | 布局属性 | Array<Number/String>/Number | - | -| direction | 布局方向,默认为 column ,一个元素占据一整行

**可选值**:
'row', 'column', 'row-reverse' | Enum | column | -| wrap | 是否折行 支持IE11+ | Boolean | false | -| spacing | 元素之间的间距 [bottom&top, right&left] | Array<Number>/Number | - | -| margin | 设置 margin [bottom&top, right&left] | Array<Number>/Number | - | -| padding | 设置 padding [bottom&top, right&left] | Array<Number>/Number | - | -| justify | 沿着主轴方向,子元素们的排布关系 (兼容性同 justify-content )

**可选值**:
'flex-start', 'center', 'flex-end', 'space-between', 'space-around' | Enum | - | -| align | 垂直主轴方向,子元素们的排布关系 (兼容性同 align-items )

**可选值**:
'flex-start', 'center', 'flex-end', 'baseline', 'stretch' | Enum | - | -| component | 定制标签名, 例如section等 | String | 'div' | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | -------- | -------- | +| flex | 同 CSS 属性 `flex`,支持数组方式设置 | \| CSS.Property.Flex
\| [CSS.Property.FlexGrow, CSS.Property.FlexShrink, CSS.Property.FlexBasis] | - | | +| direction | 布局方向,同 CSS 属性 `flex-direction` | CSS.Property.FlexDirection | 'column' | | +| wrap | 是否折行 | boolean | false | | +| spacing | 元素之间的间距 | Spacing | - | | +| margin | 容器外间距 | Spacing | - | | +| padding | 容器内间距 | Spacing | - | | +| justify | 沿着主轴方向,子元素们的排布关系,同 CSS 属性 `justify-content` | CSS.Property.JustifyContent | - | | +| align | 沿交叉轴方向,子元素们的排布关系,同 CSS 属性 `align-items` | CSS.Property.AlignItems | - | | +| component | 定制 JSX 标签名 | keyof React.JSX.IntrinsicElements | 'div' | | + +### Spacing + +```typescript +export type Spacing = + | number + | [topAndBottom: number, rightAndLeft: number] + | [top: number, rightAndLeft: number, bottom: number] + | [top: number, right: number, bottom: number, left: number]; +``` diff --git a/components/box/__docs__/theme/index.jsx b/components/box/__docs__/theme/index.jsx deleted file mode 100644 index 7e8f9ee6e8..0000000000 --- a/components/box/__docs__/theme/index.jsx +++ /dev/null @@ -1,76 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import ConfigProvider from '../../../config-provider'; -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; -import '../../style'; -import Box from '../../index'; - -const i18nMap = { - 'zh-cn': { - 'box': '弹性布局', - normal: '正常' - }, - 'en-us': { - 'box': 'Box', - normal: 'Normal', - }, -}; - -class RenderBox extends React.Component { - constructor(props) { - super(props); - this.state = { - demoFunction: { - hasChildren: { - label: 'Box使用', - value: 'false', - enum: [{ - label: '不独立使用', - value: false - }, { - label: '独立使用', - value: true - }] - } - } - }; - } - - onFunctionChange = (demoFunction) => { - this.setState({ demoFunction }); - } - - render() { - const { i18nMap } = this.props; - const { demoFunction } = this.state; - const hasChildren = demoFunction.hasChildren.value === 'true'; - - return ( - - - - - - ); - } - -} - -function render(i18nMap, lang) { - ReactDOM.render( -
- -
-
, document.getElementById('container')); -} - -window.renderDemo = function(lang = 'en-us') { - render(i18nMap[lang], lang); -}; - -renderDemo(); - -initDemo('box'); diff --git a/components/box/__docs__/theme/index.tsx b/components/box/__docs__/theme/index.tsx new file mode 100644 index 0000000000..4c9d2d1779 --- /dev/null +++ b/components/box/__docs__/theme/index.tsx @@ -0,0 +1,93 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import { Demo, DemoGroup, initDemo, DemoFunctionDefineForObject } from '../../../demo-helper'; +import ConfigProvider from '../../../config-provider'; +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; +import '../../style'; +import Box from '../../index'; + +const i18nMap = { + 'zh-cn': { + box: '弹性布局', + normal: '正常', + }, + 'en-us': { + box: 'Box', + normal: 'Normal', + }, +}; +interface RenderBoxState { + demoFunction: Record; +} + +interface RenderBoxProps { + i18nMap: { [index: string]: string }; +} +class RenderBox extends React.Component { + constructor(props: RenderBoxProps) { + super(props); + this.state = { + demoFunction: { + hasChildren: { + label: 'Box使用', + value: 'false', + enum: [ + { + label: '不独立使用', + value: false, + }, + { + label: '独立使用', + value: true, + }, + ], + }, + }, + }; + } + + onFunctionChange = (demoFunction: RenderBoxState['demoFunction']) => { + this.setState({ demoFunction }); + }; + + render() { + const { i18nMap } = this.props; + const { demoFunction } = this.state; + const hasChildren = demoFunction ? demoFunction.hasChildren.value === 'true' : null; + + return ( + + + + + + + + ); + } +} + +function render(i18nMap: { [index: string]: string }, lang: string) { + ReactDOM.render( + +
+ +
+
, + document.getElementById('container') + ); +} + +window.renderDemo = function (lang = 'en-us') { + render(i18nMap[lang], lang); +}; + +renderDemo(); + +initDemo('box'); diff --git a/components/box/__tests__/a11y-spec.js b/components/box/__tests__/a11y-spec.js deleted file mode 100644 index 4d0e504ed6..0000000000 --- a/components/box/__tests__/a11y-spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Box from '../index'; -import '../style'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Box A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should render', async () => { - wrapper = await testReact(); - return wrapper; - }); -}); diff --git a/components/box/__tests__/a11y-spec.tsx b/components/box/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..0ddf27f76b --- /dev/null +++ b/components/box/__tests__/a11y-spec.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Box from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Box A11y', () => { + describe('Box A11y', () => { + it('should render', async () => { + await testReact(); + }); + }); +}); diff --git a/components/box/__tests__/index-spec.js b/components/box/__tests__/index-spec.js deleted file mode 100644 index ec9c15452e..0000000000 --- a/components/box/__tests__/index-spec.js +++ /dev/null @@ -1,95 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Box from '../index'; -import '../style'; - -Enzyme.configure({ adapter: new Adapter() }); - -const render = element => { - let inc; - const container = document.createElement('div'); - document.body.appendChild(container); - ReactDOM.render(element, container, function() { - inc = this; - }); - return { - setProps: props => { - const clonedElement = React.cloneElement(element, props); - ReactDOM.render(clonedElement, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - }, - instance: () => { - return inc; - }, - find: selector => { - return container.querySelectorAll(selector); - }, - }; -}; - -describe('Box', () => { - let wrapper; - - beforeEach(() => { - const overlay = document.querySelectorAll('.next-overlay-wrapper'); - overlay.forEach(dom => { - document.body.removeChild(dom); - }); - }); - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should render', () => { - wrapper = render( - - - - - - - - - - - - - - - - - - - ); - - assert(wrapper.find('.next-box')); - }); - - it('justify should work when wrap and spacing setted', () => { - wrapper = mount( - - - - - - - ); - - const style = wrapper - .find('.test') - .at(2) - .prop('style'); - const { justifyContent } = style; - assert(justifyContent === 'center'); - }); -}); diff --git a/components/box/__tests__/index-spec.tsx b/components/box/__tests__/index-spec.tsx new file mode 100644 index 0000000000..94917de8ed --- /dev/null +++ b/components/box/__tests__/index-spec.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Box from '../index'; +import '../style'; + +describe('Box', () => { + it('should render', () => { + cy.mount( + + + + + + + + + + + + + + + + + + + ); + cy.get('.next-box'); + }); + + it('justify should work when wrap and spacing setted', () => { + cy.mount( + + + + + + + ); + cy.get('.test').should('have.css', 'justify-content', 'center'); + }); +}); diff --git a/components/box/index.d.ts b/components/box/index.d.ts deleted file mode 100644 index 1c1013013e..0000000000 --- a/components/box/index.d.ts +++ /dev/null @@ -1,19 +0,0 @@ -/// - -import React, { HTMLAttributes, ElementType, Component } from 'react'; -import { CommonProps } from '../util'; - -export interface BoxProps extends HTMLAttributes, CommonProps { - device?: 'phone' | 'tablet' | 'desktop'; - flex?: number | Array; - direction?: 'row' | 'column' | 'row-reverse'; - wrap?: boolean; - spacing?: number | Array; - margin?: number | Array; - padding?: number | Array; - justify?: 'flex-start' | 'center' | 'flex-end' | 'space-between' | 'space-around' | string; - align?: 'flex-start' | 'center' | 'flex-end' | 'baseline' | 'stretch' | string; - component?: keyof React.JSX.IntrinsicElements; -} - -export default class Box extends Component {} diff --git a/components/box/index.jsx b/components/box/index.jsx deleted file mode 100644 index d7f8dab85f..0000000000 --- a/components/box/index.jsx +++ /dev/null @@ -1,227 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import ConfigProvider from '../config-provider'; -import { obj } from '../util'; -import createStyle, { - getMargin, - getChildMargin, - getSpacingHelperMargin, - filterInnerStyle, - filterHelperStyle, - filterOuterStyle, - getGridChildProps, - // getBoxChildProps, -} from '../responsive-grid/create-style'; - -const { pickOthers } = obj; - -const createChildren = (children, { spacing, direction, wrap, device }) => { - const array = React.Children.toArray(children); - if (!children) { - return null; - } - - return array.map((child, index) => { - let spacingMargin = {}; - - spacingMargin = getChildMargin(spacing); - - if (!wrap) { - // 不折行 - const isNone = [index === 0, index === array.length - 1]; - const props = direction === 'row' ? ['marginLeft', 'marginRight'] : ['marginTop', 'marginBottom']; - - ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'].forEach(prop => { - if (prop in spacingMargin && props.indexOf(prop) === -1) { - spacingMargin[prop] = 0; - } - - props.forEach((key, i) => { - if (key in spacingMargin && isNone[i]) { - spacingMargin[key] = 0; - } - }); - }); - } - - if (React.isValidElement(child)) { - const { margin: propsMargin } = child.props; - const childPropsMargin = getMargin(propsMargin); - let gridProps = {}; - - if (['function', 'object'].indexOf(typeof child.type) > -1 && child.type._typeMark === 'responsive_grid') { - gridProps = createStyle({ display: 'grid', ...child.props }); - } - - return React.cloneElement(child, { - style: { - ...spacingMargin, - // ...getBoxChildProps(child.props), - ...childPropsMargin, - ...gridProps, - ...(child.props.style || {}), - }, - }); - } - - return child; - }); -}; - -const getStyle = (style = {}, props) => { - return { - ...createStyle({ display: 'flex', ...props }), - ...style, - }; -}; - -const getOuterStyle = (style, styleProps) => { - const sheet = getStyle(style, styleProps); - - return filterOuterStyle(sheet); -}; - -const getHelperStyle = (style, styleProps) => { - const sheet = getStyle(style, styleProps); - - return filterHelperStyle({ - ...sheet, - ...getSpacingHelperMargin(styleProps.spacing), - }); -}; - -const getInnerStyle = (style, styleProps) => { - const sheet = getStyle(style, styleProps); - - return filterInnerStyle(sheet); -}; - -/** - * Box - */ -class Box extends Component { - static propTypes = { - prefix: PropTypes.string, - style: PropTypes.object, - className: PropTypes.any, - /** - * 布局属性 - */ - flex: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), - PropTypes.number, - ]), - /** - * 布局方向,默认为 column ,一个元素占据一整行 - * @default column - */ - direction: PropTypes.oneOf(['row', 'column', 'row-reverse']), - /** - * 是否折行 支持IE11+ - */ - wrap: PropTypes.bool, - /** - * 元素之间的间距 [bottom&top, right&left] - */ - spacing: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - /** - * 设置 margin [bottom&top, right&left] - */ - margin: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - /** - * 设置 padding [bottom&top, right&left] - */ - padding: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), - /** - * 沿着主轴方向,子元素们的排布关系 (兼容性同 justify-content ) - */ - justify: PropTypes.oneOf(['flex-start', 'center', 'flex-end', 'space-between', 'space-around']), - /** - * 垂直主轴方向,子元素们的排布关系 (兼容性同 align-items ) - */ - align: PropTypes.oneOf(['flex-start', 'center', 'flex-end', 'baseline', 'stretch']), - device: PropTypes.oneOf(['phone', 'tablet', 'desktop']), - /** - * 定制标签名, 例如section等 - */ - component: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - direction: 'column', - wrap: false, - component: 'div', - }; - - render() { - const { - prefix, - direction, - justify, - align, - wrap, - flex, - spacing, - padding, - margin, - style, - className, - children, - device, - component, - } = this.props; - - const styleProps = { - direction, - justify, - align, - wrap, - flex, - spacing, - padding, - margin, - }; - const View = component; - const others = pickOthers(Object.keys(Box.propTypes), this.props); - const styleSheet = getStyle(style, styleProps); - - const boxs = createChildren(children, { - spacing, - direction, - wrap, - device, - }); - - const cls = cx( - { - [`${prefix}box`]: true, - }, - className - ); - if (wrap && spacing) { - const outerStyle = getOuterStyle(style, styleProps); - const helperStyle = getHelperStyle(style, styleProps); - const innerStyle = getInnerStyle(style, styleProps); - - return ( - -
-
- {boxs} -
-
-
- ); - } - - return ( - - {boxs} - - ); - } -} - -export default ConfigProvider.config(Box); diff --git a/components/box/index.tsx b/components/box/index.tsx new file mode 100644 index 0000000000..150297fe4c --- /dev/null +++ b/components/box/index.tsx @@ -0,0 +1,240 @@ +import React from 'react'; +import cx from 'classnames'; +import PropTypes from 'prop-types'; +import ConfigProvider from '../config-provider'; +import { obj } from '../util'; +import type { BoxProps } from './types'; +import createStyle, { + getMargin, + getChildMargin, + getSpacingHelperMargin, + filterInnerStyle, + filterHelperStyle, + filterOuterStyle, +} from '../responsive-grid/create-style'; + +const { pickOthers } = obj; + +type ChildElement = React.ReactElement< + BoxProps, + (string | React.JSXElementConstructor) & { _typeMark: string } +>; +const createChildren = (children: React.ReactNode, { spacing, direction, wrap }: BoxProps) => { + const array = React.Children.toArray(children); + if (!children) { + return null; + } + + return array.map((child, index) => { + let spacingMargin: { [key: string]: string | number } = {}; + + spacingMargin = getChildMargin(spacing); + + if (!wrap) { + // 不折行 + const isNone = [index === 0, index === array.length - 1]; + const props = + direction === 'row' ? ['marginLeft', 'marginRight'] : ['marginTop', 'marginBottom']; + + ['marginTop', 'marginRight', 'marginBottom', 'marginLeft'].forEach(prop => { + if (prop in spacingMargin && props.indexOf(prop) === -1) { + spacingMargin[prop] = 0; + } + + props.forEach((key, i) => { + if (key in spacingMargin && isNone[i]) { + spacingMargin[key] = 0; + } + }); + }); + } + + if (React.isValidElement(child)) { + const { margin: propsMargin } = child.props; + const childPropsMargin = getMargin(propsMargin); + let gridProps = {}; + if ( + ['function', 'object'].indexOf(typeof child.type) > -1 && + (child as ChildElement).type._typeMark === 'responsive_grid' + ) { + gridProps = createStyle({ display: 'grid', ...child.props }); + } + + return React.cloneElement(child as React.ReactElement, { + style: { + ...spacingMargin, + // ...getBoxChildProps(child.props), + ...childPropsMargin, + ...gridProps, + ...(child.props.style || {}), + }, + }); + } + + return child; + }); +}; + +const getStyle = (style: React.CSSProperties | undefined, props: BoxProps) => { + return { + // @ts-expect-error fixme: wait responsive-grid refactor to ts + ...createStyle({ display: 'flex', ...props }), + ...style, + }; +}; + +const getOuterStyle: typeof getStyle = (style, styleProps) => { + const sheet = getStyle(style, styleProps); + + return filterOuterStyle(sheet); +}; + +const getHelperStyle: typeof getStyle = (style, styleProps) => { + const sheet = getStyle(style, styleProps); + + return filterHelperStyle({ + ...sheet, + ...getSpacingHelperMargin(styleProps.spacing), + }); +}; + +const getInnerStyle: typeof getStyle = (style, styleProps) => { + const sheet = getStyle(style, styleProps); + + return filterInnerStyle(sheet); +}; + +/** + * Box + */ +class Box extends React.Component { + static propTypes = { + prefix: PropTypes.string, + style: PropTypes.object, + className: PropTypes.any, + /** + * 布局属性 + */ + flex: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.number, PropTypes.string])), + PropTypes.number, + ]), + /** + * 布局方向,默认为 column ,一个元素占据一整行 + * @defaultValue column + */ + direction: PropTypes.oneOf(['row', 'column', 'row-reverse']), + /** + * 是否折行 支持IE11+ + */ + wrap: PropTypes.bool, + /** + * 元素之间的间距 [bottom&top, right&left] + */ + spacing: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), + /** + * 设置 margin [bottom&top, right&left] + */ + margin: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), + /** + * 设置 padding [bottom&top, right&left] + */ + padding: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.number), PropTypes.number]), + /** + * 沿着主轴方向,子元素们的排布关系 (兼容性同 justify-content ) + */ + justify: PropTypes.oneOf([ + 'flex-start', + 'center', + 'flex-end', + 'space-between', + 'space-around', + ]), + /** + * 垂直主轴方向,子元素们的排布关系 (兼容性同 align-items ) + */ + align: PropTypes.oneOf(['flex-start', 'center', 'flex-end', 'baseline', 'stretch']), + device: PropTypes.oneOf(['phone', 'tablet', 'desktop']), + /** + * 定制标签名, 例如section等 + */ + component: PropTypes.string, + }; + static defaultProps = { + prefix: 'next-', + direction: 'column', + wrap: false, + component: 'div', + }; + + render() { + const { + prefix, + direction, + justify, + align, + wrap, + flex, + spacing, + padding, + margin, + style, + className, + children, + device, + component, + } = this.props; + + const styleProps = { + direction, + justify, + align, + wrap, + flex, + spacing, + padding, + margin, + }; + const View = component!; + + const others = pickOthers(Object.keys(Box.propTypes), this.props); + const styleSheet = getStyle(style, styleProps); + + const boxs = createChildren(children, { + spacing, + direction, + wrap, + device, + }); + + const cls = cx( + { + [`${prefix}box`]: true, + }, + className + ); + if (wrap && spacing) { + const outerStyle = getOuterStyle(style, styleProps); + const helperStyle = getHelperStyle(style, styleProps); + const innerStyle = getInnerStyle(style, styleProps); + + return ( + +
+
+ {boxs} +
+
+
+ ); + } + + return ( + + {boxs} + + ); + } +} +export type { BoxProps }; +export default ConfigProvider.config(Box); diff --git a/components/box/mobile/index.jsx b/components/box/mobile/index.jsx deleted file mode 100644 index a7650947fd..0000000000 --- a/components/box/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Box as MeetBox } from '@alifd/meet-react'; -import NextBox from '../index'; - -const Box = MeetBox ? MeetBox : NextBox; - -export default Box; diff --git a/components/box/mobile/index.tsx b/components/box/mobile/index.tsx new file mode 100644 index 0000000000..cae55946bf --- /dev/null +++ b/components/box/mobile/index.tsx @@ -0,0 +1,7 @@ +// @ts-expect-error meet-react does not export Box +import { Box as MeetBox } from '@alifd/meet-react'; +import NextBox from '../index'; + +const Box = MeetBox ? MeetBox : NextBox; + +export default Box; diff --git a/components/box/style.js b/components/box/style.ts similarity index 100% rename from components/box/style.js rename to components/box/style.ts diff --git a/components/box/types.ts b/components/box/types.ts new file mode 100644 index 0000000000..5c6cbb4493 --- /dev/null +++ b/components/box/types.ts @@ -0,0 +1,70 @@ +import React from 'react'; +import type * as CSS from 'csstype'; +import { CommonProps } from '../util'; + +/** + * @api + */ +export type Spacing = + | number + | [topAndRightAndBottomAndLeft: number] + | [topAndBottom: number, rightAndLeft: number] + | [top: number, rightAndLeft: number, bottom: number] + | [top: number, right: number, bottom: number, left: number] + | undefined + | null; +/** + * @api Box + */ +export interface BoxProps extends React.HTMLAttributes, CommonProps { + /** + * 同 CSS 属性 `flex`,支持数组方式设置 + * @en Same as css attribute `flex`, support array mode setting + */ + flex?: + | CSS.Property.Flex + | [CSS.Property.FlexGrow, CSS.Property.FlexShrink, CSS.Property.FlexBasis]; + /** + * 布局方向,同 CSS 属性 `flex-direction` + * @en Layout direction, same as css attribute `flex-direction` + * @defaultValue 'column' + */ + direction?: CSS.Property.FlexDirection; + /** + * 是否折行 + * @en wrap or not + * @defaultValue false + */ + wrap?: boolean; + /** + * 元素之间的间距 + * @en Element spacing + */ + spacing?: Spacing; + /** + * 容器外间距 + * @en Container outer spacing + */ + margin?: Spacing; + /** + * 容器内间距 + * @en Container inner spacing + */ + padding?: Spacing; + /** + * 沿着主轴方向,子元素们的排布关系,同 CSS 属性 `justify-content` + * @en The alignment of items on the main axis, same as css attribute `justify-content` + */ + justify?: CSS.Property.JustifyContent; + /** + * 沿交叉轴方向,子元素们的排布关系,同 CSS 属性 `align-items` + * @en The alignment of items on the cross axis, same as css attribute `align-items` + */ + align?: CSS.Property.AlignItems; + /** + * 定制 JSX 标签名 + * @en Custom JSX tag name + * @defaultValue 'div' + */ + component?: keyof React.JSX.IntrinsicElements; +} diff --git a/components/breadcrumb/__docs__/adaptor/index.jsx b/components/breadcrumb/__docs__/adaptor/index.jsx deleted file mode 100644 index ac6a197690..0000000000 --- a/components/breadcrumb/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import { Breadcrumb } from '@alifd/next'; -import { Types, parseData } from '@alifd/adaptor-helper'; - - -const _propsValue = ({ ellipsis, data, ...others }) => { - const props = ellipsis ? { maxNode: 3 } : {}; - return { - ...props, - ...others, - }; -}; - -export default { - name: 'Breadcrumb', - editor: () => ({ - props: [{ - name: 'ellipsis', - type: Types.bool, - default: false - }], - data: { - icon: true, - default: 'Home\nAll Categories\nWomen\'s Clothing\nBlouses & Shirts 78,999 T-shirts' - } - }), - propsValue: _propsValue, - adaptor: ({ ellipsis, data, ...others }) => { - const props = _propsValue({ ellipsis, ...others }); - const list = parseData(data).filter((it) => it.type === 'node'); - return ( - - { - list.map((item, index) => {item.value}) - } - - ); - }, - content: () => ({ - options: [{ - name: 'ellipsis', - options: ['yes', 'no'], - default: 'no' - }], - transform: (props, { ellipsis }) => { - return { - ...props, - ellipsis: ellipsis === 'yes' - }; - } - }) - -}; diff --git a/components/breadcrumb/__docs__/adaptor/index.tsx b/components/breadcrumb/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..1264fb9ed7 --- /dev/null +++ b/components/breadcrumb/__docs__/adaptor/index.tsx @@ -0,0 +1,63 @@ +import React from 'react'; +import { Breadcrumb } from '@alifd/next'; +import type { BreadcrumbProps } from '@alifd/next/types/breadcrumb'; +import { Types, parseData } from '@alifd/adaptor-helper'; + +interface adaptorProps extends BreadcrumbProps { + ellipsis: boolean; + data?: { icon: boolean; default: string }; +} + +const _propsValue = ({ ellipsis, data, ...others }: adaptorProps) => { + const props = ellipsis ? { maxNode: 3 } : {}; + return { + ...props, + ...others, + }; +}; + +export default { + name: 'Breadcrumb', + editor: () => ({ + props: [ + { + name: 'ellipsis', + type: Types.bool, + default: false, + }, + ], + data: { + icon: true, + default: "Home\nAll Categories\nWomen's Clothing\nBlouses & Shirts 78,999 T-shirts", + }, + }), + propsValue: _propsValue, + adaptor: ({ ellipsis, data, ...others }: adaptorProps) => { + const props: BreadcrumbProps = _propsValue({ ellipsis, ...others }); + const list = parseData(data).filter((it: { type: string }) => it.type === 'node'); + return ( + + {list.map((item: { value: string }, index: number) => ( + + {item.value} + + ))} + + ); + }, + content: () => ({ + options: [ + { + name: 'ellipsis', + options: ['yes', 'no'], + default: 'no', + }, + ], + transform: (props: BreadcrumbProps, { ellipsis }: { ellipsis: 'yes' | 'no' }) => { + return { + ...props, + ellipsis: ellipsis === 'yes', + }; + }, + }), +}; diff --git a/components/breadcrumb/__docs__/demo/custom-item/index.tsx b/components/breadcrumb/__docs__/demo/custom-item/index.tsx index cea26f25a2..4b8d600e75 100644 --- a/components/breadcrumb/__docs__/demo/custom-item/index.tsx +++ b/components/breadcrumb/__docs__/demo/custom-item/index.tsx @@ -1,7 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Breadcrumb } from '@alifd/next'; -import { HashRouter, Route, Switch, Link, withRouter } from 'react-router-dom'; +import { HashRouter, Route, Switch, Link } from 'react-router-dom'; ReactDOM.render( diff --git a/components/breadcrumb/__docs__/index.en-us.md b/components/breadcrumb/__docs__/index.en-us.md index 3a4d86b824..b6ff5c12e8 100644 --- a/components/breadcrumb/__docs__/index.en-us.md +++ b/components/breadcrumb/__docs__/index.en-us.md @@ -17,21 +17,26 @@ It is used to inform the user of the current position and the position of the cu ### Breadcrumb -| Param | Description | Type | Default Value | -| --------- | -------------------------- | --------- | ------------------------------ | -| children | Children components, hsould be an Breadcrumb.Item | custom | - | -| maxNode | The maximum number of breadcrumbs is displayed and the excess is hidden, can set auto compute maximum number | Number | 100, 'auto' | -| separator | Separator, can be text or Icon | ReactNode | <Icon type="arrow-right" /> | -| component | Set Element type | String/Function | 'nav' | - +| Param | Description | Type | Default Value | Required | Supported Version | +| --------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | ------------- | -------- | ----------------- | +| children | Children components, should be an Breadcrumb.Item | \| Array\ \| boolean \| null>
\| React.ReactElement\ | - | | - | +| maxNode | The maximum number of breadcrumbs is displayed and the excess is hidden, can set auto compute maximum number | number \| 'auto' | 100 | | - | +| showHiddenItems | When the hidden items are exceeded, is it possible to click the ellipsis to display the menu (including hidden items) | boolean | false | | 1.23 | +| popupContainer | The container node that the popup mounts (meaningful only when showHiddenItems is true) | DropdownProps['container'] | - | | 1.23 | +| followTrigger | Whether to scroll with the trigger (meaningful only when showHiddenItems is true) | boolean | - | | 1.23 | +| popupProps | The attributes added to the popup (meaningful only when showHiddenItems is true) | DropdownProps | - | | 1.23 | +| separator | Separator, can be text or Icon | string \| React.ReactNode | - | | - | +| component | Set Element type | React.ComponentType\ \| string | 'nav' | | - | + ### Breadcrumb.Item -| Param | Description | Type | Default Value | -| ---- | -------------------------------------------- | ------ | --- | -| link | The breadcrumb item link, if this property is set, the node is ``, otherwise it is `` | String | - | -| onClick | Click event | Function (event: MouseEvent) => void +| Param | Description | Type | Default Value | Required | +| ------- | -------------------------------------------------------------------------------------------------- | ------------------------------------- | ------------- | -------- | +| link | The breadcrumb item link, if this property is set, the node is ``, otherwise it is `` | string | - | | +| onClick | Click event

**signature**:
**params**:
_e_: e | React.MouseEventHandler\ | - | | + ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| Tab | switch to next item | +| KeyBoard | Descripiton | +| :------- | :------------------ | +| Tab | switch to next item | diff --git a/components/breadcrumb/__docs__/index.md b/components/breadcrumb/__docs__/index.md index da28c5e948..ac1d36a41b 100644 --- a/components/breadcrumb/__docs__/index.md +++ b/components/breadcrumb/__docs__/index.md @@ -18,26 +18,26 @@ ### Breadcrumb -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| --------------- | ------------------------------------------- | ---------------- | ----- | ---- | -| children | 面包屑子节点,需传入 Breadcrumb.Item | custom | - | | -| maxNode | 面包屑最多显示个数,超出部分会被隐藏, 设置为 auto 会自动根据父元素的宽度适配。 | Number/Enum | 100 | | -| showHiddenItems | 当超过的项被隐藏时,是否可通过点击省略号展示菜单(包含被隐藏的项) | Boolean | false | 1.23 | -| popupContainer | 弹层挂载的容器节点(在showHiddenItems为true时才有意义) | any | - | 1.23 | -| followTrigger | 是否跟随trigger滚动(在showHiddenItems为true时才有意义) | Boolean | - | 1.23 | -| popupProps | 添加到弹层上的属性(在showHiddenItems为true时才有意义) | Object | - | 1.23 | -| separator | 分隔符,可以是文本或 Icon | ReactNode/String | - | | -| component | 设置标签类型 | String/Function | 'nav' | | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| --------------- | ------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | +| children | 面包屑子节点,需传入 Breadcrumb.Item | \| Array\ \| boolean \| null>
\| React.ReactElement\ | - | | - | +| maxNode | 面包屑最多显示个数,超出部分会被隐藏 | number \| 'auto' | 100 | | - | +| showHiddenItems | 当超过的项被隐藏时,是否可通过点击省略号展示菜单(包含被隐藏的项) | boolean | false | | 1.23 | +| popupContainer | 弹层挂载的容器节点(在showHiddenItems为true时才有意义) | DropdownProps['container'] | - | | 1.23 | +| followTrigger | 是否跟随trigger滚动(在showHiddenItems为true时才有意义) | boolean | - | | 1.23 | +| popupProps | 添加到弹层上的属性(在showHiddenItems为true时才有意义) | DropdownProps | - | | 1.23 | +| separator | 分隔符,可以是文本或 Icon | string \| React.ReactNode | - | | - | +| component | 设置标签类型 | React.ComponentType\ \| string | 'nav' | | - | ### Breadcrumb.Item -| 参数 | 说明 | 类型 | 默认值 | -| ------- | --------------------------------------------------------------------------------------------------------------- | -------- | --- | -| link | 面包屑节点链接,如果设置这个属性,则该节点为`
` ,否则是`` | String | - | -| onClick | 元素点击事件

**签名**:
Function(e: MouseEvent) => void
**参数**:
_e_: {MouseEvent} Click Mouse Event | Function | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------- | ------------------------------------------------------------------------ | ------------------------------------- | ------ | -------- | +| link | 面包屑节点链接,如果设置这个属性,则该节点为`
` ,否则是`` | string | - | | +| onClick | 元素点击事件

**签名**:
**参数**:
_e_: Click Mouse Event | React.MouseEventHandler\ | - | | ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :-- | :----- | -| Tab | 切换到下一项 | +| 按键 | 说明 | +| :--- | :----------- | +| Tab | 切换到下一项 | diff --git a/components/breadcrumb/__docs__/theme/index.jsx b/components/breadcrumb/__docs__/theme/index.jsx deleted file mode 100644 index 7b10a6c147..0000000000 --- a/components/breadcrumb/__docs__/theme/index.jsx +++ /dev/null @@ -1,94 +0,0 @@ -import '../../../demo-helper/style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import '../../style'; -import Breadcrumb from '../../index'; -import Field from '../../../field'; -import ConfigProvider from '../../../config-provider'; -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; - -// import demo helper - -// import component - -// international - -const i18nMap = { - 'en-us': { - texts: ['Home', 'All Categories', 'Women\'s Clothing', 'Blouses & Shirts', 'T-shirts'], - results: 'Results', - keyword: 'Keyword' - }, - 'zh-cn': { - texts: ['首页', '所有分类', '女装', '上衣及衬衫', 'T恤'], - results: '个结果', - keyword: '关键字' - } -}; - -const demo = { - ellipsis: { - label: '节点', - value: 'normal', - enum: [{label: '全部显示', value: 'normal'}, {label: '节点省略', value: 'ellipsis'}] - } -}; -/* eslint-disable */ -class FunctionDemo extends React.Component { - field = new Field(this, { - values: { - demo: demo - } - } - ); - render() { - const { texts, results, keyword} = this.props.i18n; - const {init, getValue} = this.field; - const maxNode = getValue('demo').ellipsis.value === 'normal' ? {} : {maxNode: 4}; - return ( -
- - - - - {texts[0]} - {texts[1]} - {texts[2]} - {texts[3]} - {texts[4]} - - - - - - {texts[0]} - {texts[1]} - {texts[2]} - {texts[3]} - - {texts[4]}  - 78,999 -  {results} - - - - - -
); - } -} - -window.renderDemo = function(lang = 'en-us') { - ReactDOM.render(( - - - - ), document.getElementById('container')); - -}; - -window.renderDemo(); -initDemo('breadcrumb'); diff --git a/components/breadcrumb/__docs__/theme/index.tsx b/components/breadcrumb/__docs__/theme/index.tsx new file mode 100644 index 0000000000..708b702e00 --- /dev/null +++ b/components/breadcrumb/__docs__/theme/index.tsx @@ -0,0 +1,135 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; +import '../../style'; +import Breadcrumb from '../../index'; +import Field from '../../../field'; +import ConfigProvider from '../../../config-provider'; +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; + +type DemoValue = + | { + ellipsis?: { + label: string; + value: string; + enum: { label: string; value: string }[]; + }; + valueName?: string; + trigger?: string; + } + | undefined; + +// import demo helper + +// import component + +// international + +const i18nMap = { + 'en-us': { + texts: ['Home', 'All Categories', "Women's Clothing", 'Blouses & Shirts', 'T-shirts'], + results: 'Results', + keyword: 'Keyword', + }, + 'zh-cn': { + texts: ['首页', '所有分类', '女装', '上衣及衬衫', 'T恤'], + results: '个结果', + keyword: '关键字', + }, +}; + +const demo = { + ellipsis: { + label: '节点', + value: 'normal', + enum: [ + { label: '全部显示', value: 'normal' }, + { label: '节点省略', value: 'ellipsis' }, + ], + }, +}; +/* eslint-disable */ +class FunctionDemo extends React.Component<{ + i18n: { texts: string[]; results: string; keyword: string }; +}> { + field = new Field(this, { + values: { + demo: demo, + }, + }); + render() { + const { texts, results, keyword } = this.props.i18n; + const { init, getValue } = this.field; + const demoValue: DemoValue = getValue('demo'); + const maxNode = demoValue?.ellipsis?.value === 'normal' ? {} : { maxNode: 4 }; + return ( +
+ + + + + + {texts[0]} + + + {texts[1]} + + + {texts[2]} + + + {texts[3]} + + + {texts[4]} + + + + + + + + {texts[0]} + + + {texts[1]} + + + {texts[2]} + + + {texts[3]} + + + {texts[4]}  + 78,999 +  {results} + + + + + +
+ ); + } +} + +window.renderDemo = function (lang = 'en-us') { + ReactDOM.render( + + + , + document.getElementById('container') + ); +}; + +window.renderDemo(); +initDemo('breadcrumb'); diff --git a/components/breadcrumb/__tests__/a11y-spec.js b/components/breadcrumb/__tests__/a11y-spec.js deleted file mode 100644 index 1381e2e639..0000000000 --- a/components/breadcrumb/__tests__/a11y-spec.js +++ /dev/null @@ -1,60 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Breadcrumb from '../index'; -import '../style'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Breadcrumb A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations when empty', async () => { - wrapper = await testReact(); - return wrapper; - }); - - it('should not have any violations for breadcrumb items', async () => { - wrapper = await testReact( - - Home - - T-shirts  78,999 Results - - - ); - return wrapper; - }); - - it('should not have any violations for max node limit', async () => { - wrapper = await testReact( - - 1 - 2 - 3 - - ); - return wrapper; - }); - - it('should not have any violations for separator', async () => { - wrapper = await testReact( - - 1 - 2 - 3 - - ); - return wrapper; - }); -}); diff --git a/components/breadcrumb/__tests__/a11y-spec.tsx b/components/breadcrumb/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..a4ecf9ae4d --- /dev/null +++ b/components/breadcrumb/__tests__/a11y-spec.tsx @@ -0,0 +1,38 @@ +import React from 'react'; +import Breadcrumb from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Breadcrumb A11y', () => { + it('should not have any violations when empty', async () => { + await testReact(); + }); + it('should not have any violations for breadcrumb items', async () => { + await testReact( + + Home + + T-shirts  78,999 Results + + + ); + }); + it('should not have any violations for max node limit', async () => { + await testReact( + + 1 + 2 + 3 + + ); + }); + it('should not have any violations for separator', async () => { + await testReact( + + 1 + 2 + 3 + + ); + }); +}); diff --git a/components/breadcrumb/__tests__/index-spec.js b/components/breadcrumb/__tests__/index-spec.js deleted file mode 100644 index 2e4c0acc8f..0000000000 --- a/components/breadcrumb/__tests__/index-spec.js +++ /dev/null @@ -1,222 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import assert from 'power-assert'; -import Enzyme, { shallow, mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Breadcrumb from '../index'; -import '../style'; -import ConfigProvider from '../../config-provider'; - -Enzyme.configure({ adapter: new Adapter() }); -const { Item } = Breadcrumb; - -describe('Item', () => { - it('should has item class', () => { - const wrapper = shallow(Item); - assert(wrapper.dive().hasClass('next-breadcrumb-item')); - wrapper.unmount(); - }); - - it('should has an a tag if you pass the link property', () => { - const wrapper = mount(Item); - assert(wrapper.find('a').length === 1); - assert(wrapper.find('a').props().href === 'https://www.alibaba.com/'); - wrapper.unmount(); - }); - - it('should has an span tag if you do not pass the link property', () => { - const wrapper = mount(Item); - assert(wrapper.find('.next-breadcrumb-text').length === 1); - assert(wrapper.find('a').length === 0); - wrapper.unmount(); - }); - - it('should has an activated class if you pass it', () => { - const wrapper1 = mount( - - Item - - ); - assert(wrapper1.find('a').hasClass('activated')); - wrapper1.unmount(); - const wrapper2 = mount(Item); - assert(wrapper2.find('span').hasClass('activated')); - wrapper2.unmount(); - }); -}); - -describe('Breadcrumb', () => { - let mountNode; - - beforeEach(() => { - mountNode = document.createElement('div'); - document.body.appendChild(mountNode); - }); - - afterEach(() => { - ReactDOM.unmountComponentAtNode(mountNode); - document.body.removeChild(mountNode); - }); - - it("should throw error if you don't pass Item as children", () => { - try { - shallow(Breadcrumb); - } catch (e) { - assert(e.message === "Breadcrumb's children must be Breadcrumb.Item!"); - } - }); - - it('should render ellipsis if maxNode is less than Items count', () => { - const wrapper = mount( - - Home 1 - Whatever 2 - All Categories 3 - Women’s Clothing 4 - Blouses & Shirts 5 - T-shirts 6 - - ); - const ellipsisItem = wrapper.find('.next-breadcrumb-text').at(1); - assert(ellipsisItem.text() === '...'); - assert(ellipsisItem.find('span').hasClass('next-breadcrumb-text-ellipsis')); - wrapper.unmount(); - }); - - it('should render ellipsis if maxNode set auto', () => { - ReactDOM.render( - - Home 1 - Whatever 2 - All Categories 3 - Women’s Clothing 4 - Blouses & Shirts 5 - T-shirts 6 - , - mountNode - ); - const ellipsisItem = mountNode.querySelectorAll('.next-breadcrumb-text')[1]; - assert(ellipsisItem.textContent === '...'); - }); - - it('should show hidden items menu when ellipsis clicked if showHiddenItems set true', () => { - ReactDOM.render( - - Home 1 - Whatever 2 - All Categories 3 - Women’s Clothing 4 - Blouses & Shirts 5 - T-shirts 6 - , - mountNode - ); - const ellipsisItem = mountNode.querySelectorAll('.next-breadcrumb-text-ellipsis-clickable span')[0]; - assert.equal(ellipsisItem.textContent, '...'); - - ellipsisItem.click(); - const menuItems = document.body.querySelectorAll('.next-menu-item'); - assert.equal(menuItems.length, 2); - - const menuItem1 = menuItems[0].querySelector('.next-menu-item-text'); - assert.equal(menuItem1.textContent, 'Whatever 2'); - - const menuItem2 = menuItems[1].querySelector('.next-menu-item-text'); - assert.equal(menuItem2.textContent, 'All Categories 3'); - }); - - it('should not render the separator of the last item', () => { - const wrapper = mount( - - Home - Whatever - All Categories - - ); - assert( - wrapper - .find('.next-breadcrumb-item') - .at(2) - .find('.next-breadcrumb-separator').length === 0 - ); - wrapper.unmount(); - }); - - it('should not render the item of null', () => { - let flag = false; - const wrapper = mount( - - {flag && Default Not Show} - Whatever - All Categories - - ); - assert( - wrapper - .find('.next-breadcrumb-item') - .at(2) - .find('.next-breadcrumb-separator').length === 0 - ); - wrapper.unmount(); - }); - - it('should be set component to change element tag', () => { - const wrapper = mount( - - Home - Whatever - All Categories - - ); - - assert(wrapper.getDOMNode().tagName.toUpperCase() === 'NAV'); // default nav - - wrapper.setProps({ - component: 'div', - }); - assert(wrapper.getDOMNode().tagName.toUpperCase() === 'DIV'); - }); - - it('should support RTL', () => { - const wrapper = mount( - - - Home - Whatever - All Categories - - - ); - - assert(wrapper.find('nav').props().dir === 'rtl'); - assert( - wrapper - .find('.next-breadcrumb-item') - .at(0) - .props().dir === 'rtl' - ); - wrapper.unmount(); - }); - it('should support onClick', () => { - let isClicked = {}; - const wrapper = mount( - - Home 1 - { - isClicked = true; - }} - > - Whatever 2 - - All Categories 3 - - ); - wrapper - .find('.next-menu-item') - .at(0) - .simulate('click'); - assert(isClicked === true); - }); -}); diff --git a/components/breadcrumb/__tests__/index-spec.tsx b/components/breadcrumb/__tests__/index-spec.tsx new file mode 100644 index 0000000000..ae770c73e4 --- /dev/null +++ b/components/breadcrumb/__tests__/index-spec.tsx @@ -0,0 +1,196 @@ +import React from 'react'; +import Breadcrumb from '../index'; +import ConfigProvider from '../../config-provider'; +import '../style'; + +const { Item } = Breadcrumb; + +describe('Item', () => { + it('should has item class', () => { + cy.mount(Item); + cy.get('.next-breadcrumb-item').should('exist'); + }); + + it('should has an a tag if you pass the link property', () => { + cy.mount(Item).as('BreadcrumbItem'); + cy.get('@BreadcrumbItem').document().find('a').should('have.length', 1); + cy.get('@BreadcrumbItem') + .document() + .find('a') + .should('have.attr', 'href', 'https://www.alibaba.com/'); + }); + + it('should has an span tag if you do not pass the link property', () => { + cy.mount(Item).as('BreadcrumbItem'); + cy.get('.next-breadcrumb-item').should('have.length', 1); + cy.get('@BreadcrumbItem').document().find('a').should('have.length', 0); + }); + + it('should has an activated class if you pass it', () => { + cy.mount( + + Item + + ).as('BreadcrumbItem1'); + cy.get('@BreadcrumbItem1').document().find('a').get('.activated').should('exist'); + + cy.mount(Item).as('BreadcrumbItem2'); + cy.get('@BreadcrumbItem2').document().find('span').get('.activated').should('exist'); + }); +}); + +describe('Breadcrumb', () => { + it("should throw error if you don't pass Item as children", () => { + const spy = cy.spy(console, 'error'); + cy.mount( + +
Invalid Child
+
+ ); + expect(spy).to.have.been.calledWithMatch( + /Warning: Failed %s type: %s%s/, + 'prop', + "Breadcrumb's children must be Breadcrumb.Item!" + ); + }); + + it('should render ellipsis if maxNode is less than Items count', () => { + cy.mount( + + Home 1 + Whatever 2 + All Categories 3 + Women’s Clothing 4 + Blouses & Shirts 5 + T-shirts 6 + + ); + cy.get('.next-breadcrumb-text-ellipsis').should('exist').and('contain', '...'); + cy.get('.next-breadcrumb-item').should('have.length', 5); + cy.get('.next-breadcrumb-item:last').should('contain', 'T-shirts'); + }); + + it('should render ellipsis if maxNode set auto', () => { + cy.mount( + + Home 1 + Whatever 2 + All Categories 3 + Women’s Clothing 4 + Blouses & Shirts 5 + T-shirts 6 + + ); + cy.get('.next-breadcrumb-text-ellipsis').should('exist').and('contain', '...'); + }); + + it('should show hidden items menu when ellipsis clicked if showHiddenItems set true', () => { + cy.mount( + + Home 1 + Whatever 2 + All Categories 3 + Women’s Clothing 4 + Blouses & Shirts 5 + T-shirts 6 + + ); + cy.get('.next-breadcrumb-text-ellipsis-clickable').as('ellipsisItem'); + cy.get('@ellipsisItem').should('exist').and('contain', '...'); + cy.get('@ellipsisItem').click(); + cy.get('.next-menu-item').should('have.length', 2); + cy.get('.next-menu-item').eq(0).should('contain', 'Whatever 2'); + cy.get('.next-menu-item').eq(1).should('contain', 'All Categories 3'); + }); + + it('should not render the separator of the last item', () => { + cy.mount( + + Home + Whatever + All Categories + + ); + cy.get('.next-breadcrumb-item') + .last() + .find('.next-breadcrumb-separator') + .should('not.exist'); + }); + + it('should not render the item of null', () => { + const flag = false; + cy.mount( + + {flag && Default Not Show} + Whatever + All Categories + + ); + cy.contains('Default Not Show').should('not.exist'); + cy.get('.next-breadcrumb-item') + .last() + .find('.next-breadcrumb-separator') + .should('not.exist'); + }); + + it('should be set component to change element tag', () => { + cy.mount( + + Home + Whatever + All Categories + + ); + cy.get('.test-Breadcrumb').should('have.prop', 'tagName', 'NAV'); + cy.mount( + + Home + Whatever + All Categories + + ); + cy.get('.test-Breadcrumb').should('have.prop', 'tagName', 'DIV'); + }); + + it('should support RTL', () => { + cy.mount( + + + Home + Whatever + All Categories + + + ); + cy.get('.test-Breadcrumb').should('have.prop', 'dir', 'rtl'); + cy.get('.next-breadcrumb-item').eq(0).should('have.prop', 'dir', 'rtl'); + }); + + it('should support onClick', () => { + cy.wrap(false).as('isClicked'); + cy.mount( + + Home 1 + { + cy.get('@isClicked').then(isClicked => { + if (!isClicked) { + cy.wrap(true).as('isClicked'); + } + }); + }} + > + Whatever 2 + + All Categories 3 + + ); + cy.contains('Whatever 2').click(); + cy.get('@isClicked').should('be.true'); + }); +}); diff --git a/components/breadcrumb/index.d.ts b/components/breadcrumb/index.d.ts deleted file mode 100644 index 769b375bda..0000000000 --- a/components/breadcrumb/index.d.ts +++ /dev/null @@ -1,60 +0,0 @@ -/// - -import React from 'react'; -import { PopupProps } from '../overlay'; - -export interface ItemProps extends React.HTMLAttributes { - /** - * 面包屑节点链接,如果设置这个属性,则该节点为`
` ,否则是`` - */ - link?: string; - onClick?: React.MouseEventHandler; -} - -export class Item extends React.Component {} -export interface BreadcrumbProps extends React.HTMLAttributes { - /** - * 样式类名的品牌前缀 - */ - prefix?: string; - - /** - * 面包屑子节点,需传入 Breadcrumb.Item - */ - children?: any; - - /** - * 面包屑最多显示个数,超出部分会被隐藏 - */ - maxNode?: number | 'auto'; - - /** - * 分隔符,可以是文本或 Icon - */ - separator?: string | React.ReactNode; - - /** - * 设置标签类型 - */ - component?: string | (() => void); - /** - * 当超过的项被隐藏时,是否可通过点击省略号展示菜单(包含被隐藏的项) - */ - showHiddenItems?: boolean; - /** - * 弹层挂载的容器节点(在showHiddenItems为true时才有意义) - */ - popupContainer?: any; - /** - * 是否跟随trigger滚动(在showHiddenItems为true时才有意义) - */ - followTrigger?: boolean; - /** - * 添加到弹层上的属性(在showHiddenItems为true时才有意义) - */ - popupProps?: PopupProps; -} - -export default class Breadcrumb extends React.Component { - static Item: typeof Item; -} diff --git a/components/breadcrumb/index.jsx b/components/breadcrumb/index.jsx deleted file mode 100644 index d62e878260..0000000000 --- a/components/breadcrumb/index.jsx +++ /dev/null @@ -1,308 +0,0 @@ -import React, { Component, Children, isValidElement } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import Icon from '../icon'; -import ConfigProvider from '../config-provider'; -import Dropdown from '../dropdown'; -import Menu from '../menu'; -import Item from './item'; -import { events } from '../util'; - -/** - * Breadcrumb - */ -class Breadcrumb extends Component { - static Item = Item; - - static propTypes = { - /** - * 样式类名的品牌前缀 - */ - prefix: PropTypes.string, - rtl: PropTypes.bool, - /*eslint-disable*/ - /** - * 面包屑子节点,需传入 Breadcrumb.Item - */ - children: (props, propName) => { - Children.forEach(props[propName], child => { - if ( - !( - child && - ['function', 'object'].indexOf(typeof child.type) > -1 && - child.type._typeMark === 'breadcrumb_item' - ) - ) { - throw new Error("Breadcrumb's children must be Breadcrumb.Item!"); - } - }); - }, - /*eslint-enable*/ - /** - * 面包屑最多显示个数,超出部分会被隐藏, 设置为 auto 会自动根据父元素的宽度适配。 - */ - maxNode: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['auto'])]), - /** - * 当超过的项被隐藏时,是否可通过点击省略号展示菜单(包含被隐藏的项) - * @version 1.23 - */ - showHiddenItems: PropTypes.bool, - /** - * 弹层挂载的容器节点(在showHiddenItems为true时才有意义) - * @version 1.23 - */ - popupContainer: PropTypes.any, - /** - * 是否跟随trigger滚动(在showHiddenItems为true时才有意义) - * @version 1.23 - */ - followTrigger: PropTypes.bool, - /** - * 添加到弹层上的属性(在showHiddenItems为true时才有意义) - * @version 1.23 - */ - popupProps: PropTypes.object, - /** - * 分隔符,可以是文本或 Icon - */ - separator: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), - /** - * 设置标签类型 - */ - component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), - className: PropTypes.any, - onClick: PropTypes.func, - }; - - static defaultProps = { - prefix: 'next-', - maxNode: 100, - showHiddenItems: false, - component: 'nav', - }; - - constructor(props) { - super(props); - this.state = { - maxNode: props.maxNode === 'auto' ? 100 : props.maxNode, - }; - } - - static getDerivedStateFromProps(props, state) { - if (state.prevMaxNode === props.maxNode) { - return {}; - } - - return { - prevMaxNode: props.maxNode, - maxNode: props.maxNode === 'auto' ? 100 : props.maxNode, - }; - } - - componentDidMount() { - this.computeMaxNode(); - events.on(window, 'resize', this.computeMaxNode); - } - - componentDidUpdate() { - this.computeMaxNode(); - } - - componentWillUnmount() { - events.off(window, 'resize', this.computeMaxNode); - } - - computeMaxNode = () => { - // 计算最大node节点,无法获取到 ... 节点的宽度,目前会有 nodeWidth - ellipsisNodeWidth 的误差 - if (this.props.maxNode !== 'auto' || !this.breadcrumbEl) return; - const scrollWidth = this.breadcrumbEl.scrollWidth; - const rect = this.breadcrumbEl.getBoundingClientRect(); - - if (scrollWidth <= rect.width) return; - let maxNode = this.breadcrumbEl.children.length; - let index = 1; - let fullWidth = scrollWidth; - - while (index < this.breadcrumbEl.children.length - 1) { - const el = this.breadcrumbEl.children[index]; - maxNode--; - fullWidth -= el.getBoundingClientRect().width; - if (fullWidth <= rect.width) { - break; - } - index++; - } - - maxNode = Math.max(3, maxNode); - - if (maxNode !== this.state.maxNode) { - this.setState({ - maxNode, - }); - } - }; - - saveBreadcrumbRef = ref => { - this.breadcrumbEl = ref; - }; - - renderEllipsisNodeWithMenu(children, breakpointer) { - // 拿到被隐藏的项 - const hiddenItems = []; - Children.forEach(children, (item, i) => { - const { link, children: itemChildren, onClick } = item.props; - if (i > 0 && i <= breakpointer) { - hiddenItems.push( - - {link ? {itemChildren} : itemChildren} - - ); - } - }); - - const { prefix, followTrigger, popupContainer, popupProps } = this.props; - - return ( - ...} - {...popupProps} - container={popupContainer} - followTrigger={followTrigger} - > -
- {hiddenItems} -
-
- ); - } - - render() { - const { - prefix, - rtl, - className, - children, - component, - showHiddenItems, - maxNode: maxNodeProp, - ...others - } = this.props; - - const separator = this.props.separator || ( - - ); - - const { maxNode } = this.state; - - let items; - const length = Children.count(children); - - if (maxNode > 1 && length > maxNode) { - const breakpointer = length - maxNode + 1; - items = []; - - Children.forEach(children, (item, i) => { - const ariaProps = {}; - - // 增加空值判断 - if (!item) { - return; - } - if (i === length - 1) { - ariaProps['aria-current'] = 'page'; - } - - if (i && i === breakpointer) { - items.push( - React.cloneElement( - item, - { - separator, - prefix, - key: i, - activated: i === length - 1, - ...ariaProps, - className: showHiddenItems - ? `${prefix}breadcrumb-text-ellipsis-clickable` - : `${prefix}breadcrumb-text-ellipsis`, - }, - showHiddenItems ? this.renderEllipsisNodeWithMenu(children, breakpointer) : '...' - ) - ); - } else if (!i || i > breakpointer) { - items.push( - React.cloneElement(item, { - separator, - prefix, - key: i, - ...ariaProps, - activated: i === length - 1, - }) - ); - } - }); - } else { - items = Children.map(children, (item, i) => { - const ariaProps = {}; - // 增加空值判断 - if (!item) { - return; - } - if (i === length - 1) { - ariaProps['aria-current'] = 'page'; - } - - return React.cloneElement(item, { - separator, - prefix, - activated: i === length - 1, - ...ariaProps, - key: i, - }); - }); - } - - if (rtl) { - others.dir = 'rtl'; - } - - const BreadcrumbComponent = component; - - delete others.maxNode; - - return ( - -
    {items}
- {maxNodeProp === 'auto' ? ( -
    - {Children.map(children, (item, i) => { - return React.cloneElement(item, { - separator, - prefix, - activated: i === length - 1, - key: i, - }); - })} -
- ) : null} -
- ); - } -} - -export default ConfigProvider.config(polyfill(Breadcrumb)); diff --git a/components/breadcrumb/index.tsx b/components/breadcrumb/index.tsx new file mode 100644 index 0000000000..c385d85c62 --- /dev/null +++ b/components/breadcrumb/index.tsx @@ -0,0 +1,296 @@ +import React, { type ReactNode, type ReactElement, Component, Children } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import Icon from '../icon'; +import ConfigProvider from '../config-provider'; +import Dropdown from '../dropdown'; +import Menu from '../menu'; +import Item from './item'; +import { events } from '../util'; +import type { BreadcrumbProps } from './types'; + +export type { BreadcrumbProps, ItemProps } from './types'; + +interface Child { + type: { + _typeMark: string; + }; +} + +interface BreadcrumbState { + maxNode: number; + prevMaxNode?: BreadcrumbProps['maxNode']; +} + +/** + * Breadcrumb + */ +class Breadcrumb extends Component { + static Item = Item; + + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + children: (props: Record, propName: string) => { + Children.forEach(props[propName], (child: Child) => { + if ( + !( + child && + ['function', 'object'].indexOf(typeof child.type) > -1 && + child.type?._typeMark === 'breadcrumb_item' + ) + ) { + throw new Error("Breadcrumb's children must be Breadcrumb.Item!"); + } + }); + }, + maxNode: PropTypes.oneOfType([PropTypes.number, PropTypes.oneOf(['auto'])]), + showHiddenItems: PropTypes.bool, + popupContainer: PropTypes.any, + followTrigger: PropTypes.bool, + popupProps: PropTypes.object, + separator: PropTypes.oneOfType([PropTypes.node, PropTypes.string]), + component: PropTypes.oneOfType([PropTypes.string, PropTypes.func]), + className: PropTypes.any, + onClick: PropTypes.func, + }; + + static defaultProps = { + prefix: 'next-', + maxNode: 100, + showHiddenItems: false, + component: 'nav', + }; + + static displayName = 'Breadcrumb'; + + breadcrumbEl: HTMLUListElement; + + constructor(props: BreadcrumbProps) { + super(props); + this.state = { + maxNode: props.maxNode === 'auto' ? 100 : props.maxNode!, + }; + } + + static getDerivedStateFromProps(props: BreadcrumbProps, state: BreadcrumbState) { + if (state.prevMaxNode === props.maxNode) { + return {}; + } + + return { + prevMaxNode: props.maxNode, + maxNode: props.maxNode === 'auto' ? 100 : props.maxNode, + }; + } + + componentDidMount() { + this.computeMaxNode(); + events.on(window, 'resize', this.computeMaxNode); + } + + componentDidUpdate() { + this.computeMaxNode(); + } + + componentWillUnmount() { + events.off(window, 'resize', this.computeMaxNode); + } + + computeMaxNode = () => { + // 计算最大node节点,无法获取到 ... 节点的宽度,目前会有 nodeWidth - ellipsisNodeWidth 的误差 + if (this.props.maxNode !== 'auto' || !this.breadcrumbEl) return; + const scrollWidth = this.breadcrumbEl.scrollWidth; + const rect = this.breadcrumbEl.getBoundingClientRect(); + + if (scrollWidth <= rect.width) return; + let maxNode = this.breadcrumbEl.children.length; + let index = 1; + let fullWidth = scrollWidth; + + while (index < this.breadcrumbEl.children.length - 1) { + const el = this.breadcrumbEl.children[index]; + maxNode--; + fullWidth -= el.getBoundingClientRect().width; + if (fullWidth <= rect.width) { + break; + } + index++; + } + + maxNode = Math.max(3, maxNode); + + if (maxNode !== this.state.maxNode) { + this.setState({ + maxNode, + }); + } + }; + + saveBreadcrumbRef = (ref: HTMLUListElement) => { + this.breadcrumbEl = ref; + }; + + renderEllipsisNodeWithMenu(children: ReactNode, breakpointer: number) { + // 拿到被隐藏的项 + const hiddenItems: ReactNode[] = []; + Children.forEach(children, (item: ReactElement, i) => { + const { link, children: itemChildren, onClick } = item.props; + if (i > 0 && i <= breakpointer) { + hiddenItems.push( + + {link ? {itemChildren} : itemChildren} + + ); + } + }); + + const { prefix, followTrigger, popupContainer, popupProps } = this.props; + + return ( + ...} + {...popupProps} + container={popupContainer} + followTrigger={followTrigger} + > +
+ {hiddenItems} +
+
+ ); + } + + render() { + const { + prefix, + rtl, + className, + children, + component, + showHiddenItems, + maxNode: maxNodeProp, + ...others + } = this.props; + + const separator = this.props.separator || ( + + ); + + const maxNode = this.state.maxNode; + + let items; + const length = Children.count(children); + + if (maxNode > 1 && length > maxNode) { + const breakpointer = length - maxNode + 1; + items = []; + + Children.forEach(children, (item: ReactElement, i) => { + const ariaProps: Record = {}; + + // 增加空值判断 + if (!item) { + return; + } + if (i === length - 1) { + ariaProps['aria-current'] = 'page'; + } + + if (i && i === breakpointer) { + items.push( + React.cloneElement( + item, + { + separator, + prefix, + key: i, + activated: i === length - 1, + ...ariaProps, + className: showHiddenItems + ? `${prefix}breadcrumb-text-ellipsis-clickable` + : `${prefix}breadcrumb-text-ellipsis`, + }, + showHiddenItems + ? this.renderEllipsisNodeWithMenu(children, breakpointer) + : '...' + ) + ); + } else if (!i || i > breakpointer) { + items.push( + React.cloneElement(item, { + separator, + prefix, + key: i, + ...ariaProps, + activated: i === length - 1, + }) + ); + } + }); + } else { + items = Children.map(children, (item: ReactElement, i) => { + const ariaProps: Record = {}; + // 增加空值判断 + if (!item) { + return; + } + if (i === length - 1) { + ariaProps['aria-current'] = 'page'; + } + + return React.cloneElement(item, { + separator, + prefix, + activated: i === length - 1, + ...ariaProps, + key: i, + }); + }); + } + + if (rtl) { + others.dir = 'rtl'; + } + + const BreadcrumbComponent = component!; + + // @ts-expect-error 属性 maxNode 不存在于类型 others 上 + delete others.maxNode; + + return ( + +
    {items}
+ {maxNodeProp === 'auto' ? ( +
    + {Children.map(children, (item: ReactElement, i) => { + return React.cloneElement(item, { + separator, + prefix, + activated: i === length - 1, + key: i, + }); + })} +
+ ) : null} +
+ ); + } +} + +export default ConfigProvider.config(polyfill(Breadcrumb)); diff --git a/components/breadcrumb/item.jsx b/components/breadcrumb/item.jsx deleted file mode 100644 index 6f9c674186..0000000000 --- a/components/breadcrumb/item.jsx +++ /dev/null @@ -1,62 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Breadcrumb.Item - */ -class Item extends Component { - static propTypes = { - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 面包屑节点链接,如果设置这个属性,则该节点为`` ,否则是`` - */ - link: PropTypes.string, - activated: PropTypes.bool, - separator: PropTypes.node, - className: PropTypes.any, - children: PropTypes.node, - /** - * 元素点击事件 - * @param {MouseEvent} e Click Mouse Event - */ - onClick: PropTypes.func, - }; - - static defaultProps = { - prefix: 'next-', - }; - - static _typeMark = 'breadcrumb_item'; - - // stateless separator component - static Separator({ prefix, children }) { - return {children}; - } - - render() { - const { prefix, rtl, className, children, link, activated, separator, onClick, ...others } = this.props; - const clazz = classNames(`${prefix}breadcrumb-text`, className, { - activated, - }); - - return ( -
  • - {link ? ( - - {children} - - ) : ( - - {children} - - )} - {activated ? null : Item.Separator({ prefix, children: separator })} -
  • - ); - } -} - -export default ConfigProvider.config(Item); diff --git a/components/breadcrumb/item.tsx b/components/breadcrumb/item.tsx new file mode 100644 index 0000000000..7c29361d0b --- /dev/null +++ b/components/breadcrumb/item.tsx @@ -0,0 +1,61 @@ +import React, { type ReactNode, Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { ItemProps } from './types'; + +/** + * Breadcrumb.Item + */ +class Item extends Component { + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + link: PropTypes.string, + activated: PropTypes.bool, + separator: PropTypes.node, + className: PropTypes.any, + children: PropTypes.node, + onClick: PropTypes.func, + }; + + static defaultProps = { + prefix: 'next-', + }; + + static _typeMark = 'breadcrumb_item'; + + // stateless separator component + static Separator({ prefix, children }: { prefix?: string; children: ReactNode }) { + return {children}; + } + + render() { + const { prefix, rtl, className, children, link, activated, separator, onClick, ...others } = + this.props; + const clazz = classNames(`${prefix}breadcrumb-text`, className, { + activated, + }); + + return ( +
  • + {link ? ( + + {children} + + ) : ( + + {children} + + )} + {activated ? null : Item.Separator({ prefix, children: separator })} +
  • + ); + } +} + +export default ConfigProvider.config(Item); diff --git a/components/breadcrumb/mobile/index.jsx b/components/breadcrumb/mobile/index.tsx similarity index 100% rename from components/breadcrumb/mobile/index.jsx rename to components/breadcrumb/mobile/index.tsx diff --git a/components/breadcrumb/style.js b/components/breadcrumb/style.ts similarity index 100% rename from components/breadcrumb/style.js rename to components/breadcrumb/style.ts diff --git a/components/breadcrumb/types.ts b/components/breadcrumb/types.ts new file mode 100644 index 0000000000..0023275849 --- /dev/null +++ b/components/breadcrumb/types.ts @@ -0,0 +1,87 @@ +import type React from 'react'; +import type { DropdownProps } from '../dropdown'; +import type { CommonProps } from '../util'; + +/** + * @api Breadcrumb.Item + */ +export interface ItemProps extends React.HTMLAttributes, CommonProps { + /** + * 面包屑节点链接,如果设置这个属性,则该节点为``,否则是`` + * @en The breadcrumb item link, if this property is set, the node is ``, otherwise it is `` + */ + link?: string; + /** + * 元素点击事件 + * @en Click event + * @param e - Click Mouse Event + */ + onClick?: React.MouseEventHandler; + /** + * 是否激活 + * @en Is it activated + * @skip + */ + activated?: boolean; + /** + * 分隔符,可以是文本或 Icon + * @en Separator, can be text or Icon + * @skip + */ + separator?: string | React.ReactNode; +} + +/** + * @api Breadcrumb + */ +export interface BreadcrumbProps extends React.HTMLAttributes, CommonProps { + /** + * 面包屑子节点,需传入 Breadcrumb.Item + * @en Children components, should be an Breadcrumb.Item + */ + children?: + | Array | boolean | null> + | React.ReactElement; + /** + * 面包屑最多显示个数,超出部分会被隐藏 + * @en The maximum number of breadcrumbs is displayed and the excess is hidden, can set auto compute maximum number + * @defaultValue 100 + */ + maxNode?: number | 'auto'; + /** + * 当超过的项被隐藏时,是否可通过点击省略号展示菜单(包含被隐藏的项) + * @en When the hidden items are exceeded, is it possible to click the ellipsis to display the menu (including hidden items) + * @defaultValue false + * @version 1.23 + */ + showHiddenItems?: boolean; + /** + * 弹层挂载的容器节点(在 showHiddenItems 为 true 时才有意义) + * @en The container node that the popup mounts (meaningful only when showHiddenItems is true) + * @version 1.23 + */ + popupContainer?: DropdownProps['container']; + /** + * 是否跟随 trigger 滚动(在 showHiddenItems 为 true 时才有意义) + * @en Whether to scroll with the trigger (meaningful only when showHiddenItems is true) + * @version 1.23 + */ + followTrigger?: boolean; + /** + * 添加到弹层上的属性(在 showHiddenItems 为 true 时才有意义) + * @en The attributes added to the popup (meaningful only when showHiddenItems is true) + * @version 1.23 + */ + popupProps?: Partial; + /** + * 分隔符,可以是文本或 Icon + * @en Separator, can be text or Icon + */ + separator?: string | React.ReactNode; + /** + * 设置标签类型 + * @en Set Element type + * @defaultValue 'nav' + */ + component?: React.ComponentType | string; +} diff --git a/components/button/__docs__/adaptor/index.tsx b/components/button/__docs__/adaptor/index.tsx index 61ae5de9e8..fa357605f9 100644 --- a/components/button/__docs__/adaptor/index.tsx +++ b/components/button/__docs__/adaptor/index.tsx @@ -62,7 +62,7 @@ export default { }, propsValue: _propsValue, adaptor: ({ shape, level, size, data, ...others }: any) => { - const list = parseData(data, { parseContent: true }); + const list = parseData(data, { parseContent: true }) as { state: string; value: unknown }[]; const buttonProps = _propsValue({ shape, level, size, data, ...others }); diff --git a/components/button/__docs__/index.en-us.md b/components/button/__docs__/index.en-us.md index f302d812e1..683b99a430 100644 --- a/components/button/__docs__/index.en-us.md +++ b/components/button/__docs__/index.en-us.md @@ -19,31 +19,36 @@ Buttons are used for emphasizing important functions on your page. ### Button -| Param | Description | Type | Default Value | -| --------- | ------------------------------------------------------------------------------------------------------------ | -------- | -------- | -| size | Size of button

    **return**:
    'small', 'medium', 'large' | Enum | 'medium' | -| type | Typeo of button

    **return**:
    'primary', 'secondary', 'normal' | Enum | 'normal' | -| icons | custom icons in button in the format { loading: } | Object | {} | -| iconSize | Size of icon in button

    **return**:
    'xxs', 'xs', 'small', 'medium', 'large', 'xl', 'xxl', 'xxxl' | Enum | - | -| htmlType | Original html type

    **return**:
    'submit', 'reset', 'button' | Enum | 'button' | -| component | The html tag to be rendered

    **return**:
    'button', 'a', 'div', 'span' | Enum | 'button' | -| loading | Loading state of a button | Boolean | false | -| ghost | Setting ghost button

    **return**:
    true, false, 'light', 'dark' | Enum | false | -| text | Setting text button | Boolean | false | -| warning | Settting warning button | Boolean | false | -| disabled | whether is disabled | Boolean | false | -| onClick | Callback of click event

    **signature**:
    Function(e: Object) => void
    **return**:
    _e_: {Object} Event Object | Function | () => {} | +| Param | Description | Type | Default Value | Required | +| --------- | ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------- | +| type | Typeo of button | 'primary' \| 'secondary' \| 'normal' | 'normal' | | +| size | Size of button | ButtonSize | 'medium' | | +| icons | Available icons in button | { loading?: React.ReactNode } | - | | +| iconSize | Size of icon in button | \| number
    \| 'xxs'
    \| 'xs'
    \| 'small'
    \| 'medium'
    \| 'large'
    \| 'xl'
    \| 'xxl'
    \| 'xxxl'
    \| 'inherit' | 默认根据 size 自动映射,映射规则:
    size:large -\> `small`
    size:medium -\> `xs`
    size:small -\> `xs` | | +| htmlType | Original html type for button element | 'submit' \| 'reset' \| 'button' | 'button' | | +| component | The jsx tag to be rendered | 'button' \| 'a' \| React.ComponentType\ | - | | +| loading | Loading state of a button | boolean | false | | +| ghost | Setting ghost button | true \| false \| 'light' \| 'dark' | false | | +| text | Is text button | boolean | false | | +| warning | Is warning button | boolean | false | | +| disabled | Is disabled | boolean | false | | +| onClick | Callback of click event | React.MouseEventHandler | - | | ### Button.Group -| Param | Description | Type | Default Value | -| ---- | ------------------- | ------ | -------- | -| size | Size of buttons in group | String | 'medium' | +| Param | Description | Type | Default Value | Required | +| ----- | ----------- | ---------- | ------------- | -------- | +| size | - | ButtonSize | - | | +### ButtonSize + +```typescript +export type ButtonSize = 'small' | 'medium' | 'large'; +``` ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| Enter | Trigger the onClick event | -| SPACE | Trigger the onClick event | +| KeyBoard | Descripiton | +| :------- | :------------------------ | +| Enter | Trigger the onClick event | +| SPACE | Trigger the onClick event | diff --git a/components/button/__docs__/index.md b/components/button/__docs__/index.md index 4a6f447873..342c9cae22 100644 --- a/components/button/__docs__/index.md +++ b/components/button/__docs__/index.md @@ -17,30 +17,38 @@ ### Button -| 参数 | 说明 | 类型 | 默认值 | -| --------- | --------------------------------------------------------------------------------------------------- | ----------- | -------- | -| size | 按钮的尺寸

    **可选值**:
    'small', 'medium', 'large' | Enum | 'medium' | -| type | 按钮的类型

    **可选值**:
    'primary', 'secondary', 'normal' | Enum | 'normal' | -| icons | 按钮中可配置的 Icon,格式为 { loading: } | Object | {} | -| iconSize | 按钮中 Icon 的尺寸,用于替代 Icon 的默认大小 | Enum/Number | - | -| htmlType | 当 component = 'button' 时,设置 button 标签的 type 值

    **可选值**:
    'submit', 'reset', 'button' | Enum | 'button' | -| component | 设置标签类型

    **可选值**:
    'button', 'a', 'div', 'span' | Enum | 'button' | -| loading | 设置按钮的载入状态 | Boolean | false | -| ghost | 是否为幽灵按钮

    **可选值**:
    true, false, 'light', 'dark' | Enum | false | -| text | 是否为文本按钮 | Boolean | false | -| warning | 是否为警告按钮 | Boolean | false | -| disabled | 是否禁用 | Boolean | false | -| onClick | 点击按钮的回调

    **签名**:
    Function(e: Object) => void
    **参数**:
    _e_: {Object} Event Object | Function | () => {} | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------- | +| type | 按钮的类型 | 'primary' \| 'secondary' \| 'normal' | 'normal' | | +| size | 按钮的尺寸 | ButtonSize | 'medium' | | +| icons | 按钮中可配置的 Icon | { loading?: React.ReactNode } | - | | +| iconSize | 按钮中 Icon 的尺寸 | \| number
    \| 'xxs'
    \| 'xs'
    \| 'small'
    \| 'medium'
    \| 'large'
    \| 'xl'
    \| 'xxl'
    \| 'xxxl'
    \| 'inherit' | 默认根据 size 自动映射,映射规则:
    size:large -\> `small`
    size:medium -\> `xs`
    size:small -\> `xs` | | +| htmlType | button 标签的 type 值 | 'submit' \| 'reset' \| 'button' | 'button' | | +| component | 最终渲染的 jsx 标签标签类型 | 'button' \| 'a' \| React.ComponentType\ | - | | +| loading | 设置按钮的载入状态 | boolean | false | | +| ghost | 是否为幽灵按钮 | true \| false \| 'light' \| 'dark' | false | | +| text | 是否为文本按钮 | boolean | false | | +| warning | 是否为警告按钮 | boolean | false | | +| disabled | 是否禁用 | boolean | false | | +| onClick | 点击按钮的回调 | React.MouseEventHandler | - | | ### Button.Group -| 参数 | 说明 | 类型 | 默认值 | -| ---- | ------------------- | ------ | -------- | -| size | 统一设置 Button 组件的按钮大小 | String | 'medium' | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ---- | ------------------------------ | ---------- | ------ | -------- | +| size | 统一设置 Button 组件的按钮大小 | ButtonSize | - | | + +### ButtonSize + +按钮类型 + +```typescript +export type ButtonSize = 'small' | 'medium' | 'large'; +``` ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---- | :---------- | +| 按键 | 说明 | +| :---- | :-------------- | | Enter | 触发onClick事件 | | SPACE | 触发onClick事件 | diff --git a/components/button/index.tsx b/components/button/index.tsx index 14d7f558da..57e3f99c6d 100644 --- a/components/button/index.tsx +++ b/components/button/index.tsx @@ -1,12 +1,10 @@ import ConfigProvider from '../config-provider'; +import { assignSubComponent } from '../util/component'; import type { ButtonProps, GroupProps } from './types'; import Button from './view/button'; import Group from './view/group'; -const WithSubButton = Button as typeof Button & { - Group: typeof Group; -}; -WithSubButton.Group = Group; +const WithSubButton = assignSubComponent(Button, { Group }); export type { ButtonProps, GroupProps }; diff --git a/components/button/view/button.tsx b/components/button/view/button.tsx index f5865ffd71..db5e90d27f 100644 --- a/components/button/view/button.tsx +++ b/components/button/view/button.tsx @@ -14,6 +14,7 @@ function mapIconSize(size: NonNullable): ButtonProps['iconS } export default class Button extends Component { + static displayName = 'Button'; static propTypes = { ...ConfigProvider.propTypes, prefix: PropTypes.string, diff --git a/components/button/view/group.tsx b/components/button/view/group.tsx index ff3b7b095c..04d368dc7d 100644 --- a/components/button/view/group.tsx +++ b/components/button/view/group.tsx @@ -8,6 +8,7 @@ import ConfigProvider from '../../config-provider'; * Button.Group */ class ButtonGroup extends Component { + static displayName = 'ButtonGroup'; static propTypes = { ...ConfigProvider.propTypes, rtl: PropTypes.bool, @@ -51,4 +52,6 @@ class ButtonGroup extends Component { } } +export type { ButtonGroup }; + export default ConfigProvider.config(ButtonGroup); diff --git a/components/calendar/__docs__/adaptor/index.jsx b/components/calendar/__docs__/adaptor/index.jsx deleted file mode 100644 index 3bb8a30b22..0000000000 --- a/components/calendar/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,72 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import { Calendar } from '@alifd/next'; -import { Types } from '@alifd/adaptor-helper'; - -const now = new Date(); - -export default { - name: 'Calendar', - shape: ['fullscreen', 'card', 'panel', 'rangePanel'], - editor: (shape) => { - return { - props: [{ - name: 'level', - label: 'Type', - type: Types.enum, - options: ['day', 'month', 'year'].filter((level) => { - if (level === 'year') return shape === 'panel'; - if (shape === 'rangePanel') return level === 'day'; - - return true; - }), - default: 'day' - }, { - name: 'width', - type: Types.number, - default: shape === 'fullscreen' ? 600 : shape === 'rangePanel' ? 600 : 320, - }, { - name: 'date', - type: Types.string, - default: `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}` - }] - }; - }, - adaptor: ({ shape, level, width, date = '', style = {}, ...others }) => { - const arr = date.split('-').map(number => Number(number) || 0); - - const d = moment(); - arr.forEach((number, index) => { - if (!number) return; - switch (index) { - case 0: - d.year(number); - break; - case 1: - d.month(number - 1); - break; - case 2: - d.date(number); - break; - default: return; - } - }); - - if (shape === 'rangePanel') { - if (!Calendar.RangeCalendar) return null; - - return ( - - ); - } - return ( - - ); - } -}; diff --git a/components/calendar/__docs__/adaptor/index.tsx b/components/calendar/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..67958b5fa6 --- /dev/null +++ b/components/calendar/__docs__/adaptor/index.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import moment from 'moment'; +import { Calendar } from '@alifd/next'; +import { Types } from '@alifd/adaptor-helper'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; + +const now = new Date(); + +export default { + name: 'Calendar', + shape: ['fullscreen', 'card', 'panel', 'rangePanel'], + editor: (shape: string) => { + return { + props: [ + { + name: 'level', + label: 'Type', + type: Types.enum, + options: ['day', 'month', 'year'].filter(level => { + if (level === 'year') return shape === 'panel'; + if (shape === 'rangePanel') return level === 'day'; + + return true; + }), + default: 'day', + }, + { + name: 'width', + type: Types.number, + default: shape === 'fullscreen' ? 600 : shape === 'rangePanel' ? 600 : 320, + }, + { + name: 'date', + type: Types.string, + default: `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`, + }, + ], + }; + }, + adaptor: ({ + shape, + level, + width, + date = '', + style = {}, + ...others + }: { + shape: string; + width: number; + level: string; + date: string; + style: any; + }) => { + const arr = date.split('-').map(number => Number(number) || 0); + + const d = moment(); + arr.forEach((number, index) => { + if (!number) return; + switch (index) { + case 0: + d.year(number); + break; + case 1: + d.month(number - 1); + break; + case 2: + d.date(number); + break; + default: + return; + } + }); + + if (shape === 'rangePanel') { + if (!Calendar.RangeCalendar) return null; + + return ( + + ); + } + return ( + + ); + }, +}; diff --git a/components/calendar/__docs__/demo/basic/index.tsx b/components/calendar/__docs__/demo/basic/index.tsx index b114dba9b0..6698155990 100644 --- a/components/calendar/__docs__/demo/basic/index.tsx +++ b/components/calendar/__docs__/demo/basic/index.tsx @@ -2,10 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar } from '@alifd/next'; import moment from 'moment'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; -function onDateChange(value) { +const onDateChange: CalendarProps['onSelect'] = value => { console.log(value.format('L')); -} +}; ReactDOM.render(
    diff --git a/components/calendar/__docs__/demo/card/index.tsx b/components/calendar/__docs__/demo/card/index.tsx index 6f1a05e39f..d9e0fca916 100644 --- a/components/calendar/__docs__/demo/card/index.tsx +++ b/components/calendar/__docs__/demo/card/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar } from '@alifd/next'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; -function onDateChange(value) { - console.log(value); -} +const onDateChange: CalendarProps['onSelect'] = value => { + console.log(value.format('L')); +}; ReactDOM.render(
    diff --git a/components/calendar/__docs__/demo/custom-cell/index.tsx b/components/calendar/__docs__/demo/custom-cell/index.tsx index 67937d58ad..c1ff4985b8 100644 --- a/components/calendar/__docs__/demo/custom-cell/index.tsx +++ b/components/calendar/__docs__/demo/custom-cell/index.tsx @@ -2,18 +2,19 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar } from '@alifd/next'; import moment from 'moment'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; const currentDate = moment(); const localeData = currentDate.clone().localeData(); const monthLocale = localeData.monthsShort(); -function dateCellRender(date) { +const dateCellRender: CalendarProps['dateCellRender'] = date => { const dateNum = date.date(); if (currentDate.month() !== date.month()) { return dateNum; } - let eventList; + let eventList: { type: 'primary' | 'normal'; content: string }[]; switch (dateNum) { case 1: eventList = [ @@ -51,9 +52,9 @@ function dateCellRender(date) {
    ); -} +}; -function monthCellRender(date) { +const monthCellRender: CalendarProps['monthCellRender'] = date => { if (currentDate.month() === date.month()) { return (
    @@ -63,7 +64,7 @@ function monthCellRender(date) { ); } return monthLocale[date.month()]; -} +}; ReactDOM.render( , diff --git a/components/calendar/__docs__/demo/default-visible-month/index.tsx b/components/calendar/__docs__/demo/default-visible-month/index.tsx index 838ccd12d9..145b93d49b 100644 --- a/components/calendar/__docs__/demo/default-visible-month/index.tsx +++ b/components/calendar/__docs__/demo/default-visible-month/index.tsx @@ -2,14 +2,15 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar } from '@alifd/next'; import moment from 'moment'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; -function onSelect(value) { +const onSelect: CalendarProps['onSelect'] = value => { console.log(value.format('L')); -} +}; -function onVisibleMonthChange(value, reason) { +const onVisibleMonthChange: CalendarProps['onVisibleMonthChange'] = (value, reason) => { console.log('Visible month changed to %s from <%s>', value.format('YYYY-MM'), reason); -} +}; ReactDOM.render( currentDate.valueOf(); }; diff --git a/components/calendar/__docs__/demo/lunar/index.tsx b/components/calendar/__docs__/demo/lunar/index.tsx index f8141fabeb..b26dd13433 100644 --- a/components/calendar/__docs__/demo/lunar/index.tsx +++ b/components/calendar/__docs__/demo/lunar/index.tsx @@ -3,12 +3,13 @@ import ReactDOM from 'react-dom'; import { Calendar } from '@alifd/next'; import moment from 'moment'; import solarLunar from 'solarlunar'; +import { type CalendarProps } from '@alifd/next/lib/calendar'; -function onDateChange(value) { +const onDateChange: CalendarProps['onSelect'] = value => { console.log(value.format('L')); -} +}; -function dateCellRender(value) { +const dateCellRender: CalendarProps['dateCellRender'] = value => { const solar2lunarData = solarLunar.solar2lunar(value.year(), value.month(), value.date()); return ( @@ -19,7 +20,7 @@ function dateCellRender(value) {
    ); -} +}; ReactDOM.render(
    diff --git a/components/calendar/__docs__/index.en-us.md b/components/calendar/__docs__/index.en-us.md index fe36158e4c..66299b9404 100644 --- a/components/calendar/__docs__/index.en-us.md +++ b/components/calendar/__docs__/index.en-us.md @@ -29,18 +29,52 @@ moment.locale('zh-cn'); ### Calendar -| Param | Description | Type | Default Value | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------- | -| defaultValue | Default value of calendar | custom | - | -| shape | Shape of calendar

    **option**:
    'card', 'fullscreen', 'panel' | Enum | 'fullscreen' | -| value | Value of calendar | custom | - | -| mode | Mode of panel

    **option**:
    'date', 'month', 'year' | Enum | 'date' | -| showOtherMonth | Show dates of other month in current date | Boolean | true | -| defaultVisibleMonth | Default visible month of panel

    **signature**:
    Function() => void | Function | - | -| onSelect | Callback when select a date

    **signature**:
    Function(value: Object) => void
    **parameter**:
    _value_: {Object} date object | Function | func.noop | -| onModeChange | Callback when change mode

    **签名**:
    Function(mode: string) => void
    **参数**:
    _mode_: {string} mode type: date month year | Function | func.noop | -| dateCellRender | Render function for date cell

    **signature**:
    Function(value: Object) => ReactNode
    **parameter**:
    _value_: {Object} date object
    **return**:
    {ReactNode} null
    | Function | (value) => value.date() | -| monthCellRender | Render function for month cell

    **signature**:
    Function(calendarDate: Object) => ReactNode
    **parameter**:
    _calendarDate_: {Object} current date object
    **return**:
    {ReactNode} null
    | Function | - | -| yearRange | Year Range,[START_YEAR, END_YEAR] \(only shape in ‘card’, 'fullscreen') | Array<Number> | - | -| disabledDate | Function to disable dates

    **signature**:
    Function(calendarDate: Object) => Boolean
    **parameter**:
    _calendarDate_: {Object} current date object
    _view_: {Enum} current view type: 'year', 'month', 'date'
    **return**:
    {Boolean} null
    | Function | - | +| Param | Description | Type | Default Value | Required | +| -------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------------- | ------------------------- | -------- | +| defaultValue | Default selected date (moment object) | Moment \| null | - | | +| shape | Display shape | 'card' \| 'fullscreen' \| 'panel' | 'fullscreen' | | +| value | Selected date value (moment object) | Moment \| null | - | | +| mode | Panel mode | CalendarMode | - | | +| showOtherMonth | Whether to show dates outside the current month | boolean | true | | +| defaultVisibleMonth | Default displayed month | () => Moment \| null | - | | +| onModeChange | Callback when the panel mode changes

    **signature**:
    **params**:
    _mode_: mode | (mode: CalendarMode) => void | - | | +| onSelect | Callback when selecting a date cell | (value: Moment) => void | - | | +| onVisibleMonthChange | Callback when the displayed month changes | (value: Moment, reason: VisibleMonthChangeType) => void | - | | +| dateCellRender | Customize date rendering function | (value: Moment) => React.ReactNode | value =\> value.date() | | +| monthCellRender | Customize month rendering function | (calendarDate: Moment) => React.ReactNode | - | | +| disabledDate | Disabled date | (calendarDate: Moment, view: CalendarMode) => boolean | - | | +| modes | Panel mode list that can be changed, only received once at initialization | CalendarMode[] | ['date', 'month', 'year'] | | +| format | Date value format(for date title display format) | string | 'YYYY-MM | | +| yearRange | Year range, [START_YEAR, END_YEAR] (only effective when shape is 'card', 'fullscreen') | [start: number, end: number] | - | | +### Calendar.RangeCalendar + +| Param | Description | Type | Default Value | Required | +| -------------------- | -------------------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------- | -------- | +| mode | Panel mode | CalendarMode | 'date' | | +| format | Date value format(for date title display format) | string | 'YYYY-MM | | +| dateCellRender | Customize date rendering function | (value: Moment) => React.ReactNode | value =\> value.date() | | +| onSelect | Callback when selecting a date cell | (value: Moment) => void | - | | +| onVisibleMonthChange | Callback when the displayed month changes | (value: Moment, reason: VisibleMonthChangeType) => void | - | | +| showOtherMonth | Whether to show dates outside the current month | boolean | true | | +| startValue | Start date (moment object) | Moment \| null | - | | +| endValue | End date (moment object) | Moment \| null | - | | +| defaultStartValue | Default start date (moment object) | Moment \| null | - | | +| defaultEndValue | Default end date (moment object) | Moment \| null | - | | +| monthCellRender | Customize month rendering function | (calendarDate: Moment) => React.ReactNode | - | | +| defaultVisibleMonth | Default displayed month | () => Moment \| null | - | | +| disabledDate | Disabled date | (calendarDate: Moment, view: CalendarMode) => boolean | - | | +| shape | Display shape | 'card' \| 'fullscreen' \| 'panel' | - | | +| yearRange | Year range, [START_YEAR, END_YEAR] (only effective when shape is 'card', 'fullscreen') | [number, number] | - | | + +### CalendarMode + +```typescript +export type CalendarMode = 'date' | 'month' | 'year'; +``` + +### VisibleMonthChangeType + +```typescript +export type VisibleMonthChangeType = 'cellClick' | 'buttonClick' | 'yearSelect' | 'monthSelect'; +``` diff --git a/components/calendar/__docs__/index.md b/components/calendar/__docs__/index.md index b32019804a..a237b93b39 100644 --- a/components/calendar/__docs__/index.md +++ b/components/calendar/__docs__/index.md @@ -27,18 +27,52 @@ moment.locale('zh-cn'); ### Calendar -| 参数 | 说明 | 类型 | 默认值 | -| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ---------------- | --------------------- | -| defaultValue | 默认选中的日期(moment 对象) | custom | - | -| shape | 展现形态

    **可选值**:
    'card', 'fullscreen', 'panel' | Enum | 'fullscreen' | -| value | 选中的日期值 (moment 对象) | custom | - | -| mode | 面板模式 | Enum | - | -| showOtherMonth | 是否展示非本月的日期 | Boolean | true | -| defaultVisibleMonth | 默认展示的月份

    **签名**:
    Function() => void | Function | - | -| onSelect | 选择日期单元格时的回调

    **签名**:
    Function(value: Object) => void
    **参数**:
    _value_: {Object} 对应的日期值 (moment 对象) | Function | func.noop | -| onModeChange | 面板模式变化时的回调

    **签名**:
    Function(mode: String) => void
    **参数**:
    _mode_: {String} 对应面板模式 date month year | Function | func.noop | -| onVisibleMonthChange | 展现的月份变化时的回调

    **签名**:
    Function(value: Object, reason: String) => void
    **参数**:
    _value_: {Object} 显示的月份 (moment 对象)
    _reason_: {String} 触发月份改变原因 | Function | func.noop | -| dateCellRender | 自定义日期渲染函数

    **签名**:
    Function(value: Object) => ReactNode
    **参数**:
    _value_: {Object} 日期值(moment对象)
    **返回值**:
    {ReactNode} null
    | Function | value => value.date() | -| monthCellRender | 自定义月份渲染函数

    **签名**:
    Function(calendarDate: Object) => ReactNode
    **参数**:
    _calendarDate_: {Object} 对应 Calendar 返回的自定义日期对象
    **返回值**:
    {ReactNode} null
    | Function | - | -| yearRange | 年份范围,[START_YEAR, END_YEAR] \(只在shape 为 ‘card’, 'fullscreen' 下生效) | Array<Number> | - | -| disabledDate | 不可选择的日期

    **签名**:
    Function(calendarDate: Object, view: String) => Boolean
    **参数**:
    _calendarDate_: {Object} 对应 Calendar 返回的自定义日期对象
    _view_: {String} 当前视图类型,year: 年, month: 月, date: 日
    **返回值**:
    {Boolean} null
    | Function | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| -------------------- | ------------------------------------------------------------------------------------------------ | ------------------------------------------------------- | ------------------------- | -------- | +| defaultValue | 默认选中的日期(moment 对象) | Moment \| null | - | | +| shape | 展现形态 | 'card' \| 'fullscreen' \| 'panel' | 'fullscreen' | | +| value | 选中的日期值 (moment 对象) | Moment \| null | - | | +| mode | 面板模式 | CalendarMode | - | | +| showOtherMonth | 是否展示非本月的日期 | boolean | true | | +| defaultVisibleMonth | 默认展示的月份 | () => Moment \| null | - | | +| onModeChange | 面板模式变化时的回调

    **签名**:
    **参数**:
    _mode_: 对应面板模式 date, month, year | (mode: CalendarMode) => void | - | | +| onSelect | 选择日期单元格时的回调 | (value: Moment) => void | - | | +| onVisibleMonthChange | 展现的月份变化时的回调 | (value: Moment, reason: VisibleMonthChangeType) => void | - | | +| dateCellRender | 自定义日期渲染函数 | (value: Moment) => React.ReactNode | value =\> value.date() | | +| monthCellRender | 自定义月份渲染函数 | (calendarDate: Moment) => React.ReactNode | - | | +| disabledDate | 不可选择的日期 | (calendarDate: Moment, view: CalendarMode) => boolean | - | | +| modes | 面板可变化的模式列表,仅初始化时接收一次 | CalendarMode[] | ['date', 'month', 'year'] | | +| format | 日期值的格式(用于日期 title 显示的格式) | string | 'YYYY-MM | | +| yearRange | 年份范围,[START_YEAR, END_YEAR] (只在 shape 为‘card’, 'fullscreen' 下生效) | [start: number, end: number] | - | | + +### Calendar.RangeCalendar + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| -------------------- | --------------------------------------------------------------------------- | ------------------------------------------------------- | ---------------------- | -------- | +| mode | 面板模式 | CalendarMode | 'date' | | +| format | 日期值的格式(用于日期 title 显示的格式) | string | 'YYYY-MM | | +| dateCellRender | 自定义日期渲染函数 | (value: Moment) => React.ReactNode | value =\> value.date() | | +| onSelect | 选择日期单元格时的回调 | (value: Moment) => void | - | | +| onVisibleMonthChange | 展现的月份变化时的回调 | (value: Moment, reason: VisibleMonthChangeType) => void | - | | +| showOtherMonth | 是否展示非本月的日期 | boolean | true | | +| startValue | 开始日期(moment 对象) | Moment \| null | - | | +| endValue | 结束日期(moment 对象) | Moment \| null | - | | +| defaultStartValue | 默认的开始日期(moment 对象) | Moment \| null | - | | +| defaultEndValue | 默认的结束日期(moment 对象) | Moment \| null | - | | +| monthCellRender | 自定义月份渲染函数 | (calendarDate: Moment) => React.ReactNode | - | | +| defaultVisibleMonth | 默认展示的月份 | () => Moment \| null | - | | +| disabledDate | 不可选择的日期 | (calendarDate: Moment, view: CalendarMode) => boolean | - | | +| shape | 展现形态 | 'card' \| 'fullscreen' \| 'panel' | - | | +| yearRange | 年份范围,[START_YEAR, END_YEAR] (只在 shape 为‘card’, 'fullscreen' 下生效) | [number, number] | - | | + +### CalendarMode + +```typescript +export type CalendarMode = 'date' | 'month' | 'year'; +``` + +### VisibleMonthChangeType + +```typescript +export type VisibleMonthChangeType = 'cellClick' | 'buttonClick' | 'yearSelect' | 'monthSelect'; +``` diff --git a/components/calendar/__docs__/theme/index.jsx b/components/calendar/__docs__/theme/index.jsx deleted file mode 100644 index ec801c8ccb..0000000000 --- a/components/calendar/__docs__/theme/index.jsx +++ /dev/null @@ -1,136 +0,0 @@ -import moment from 'moment'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import Calendar from '../../index'; -import RangeCalendar from '../../range-calendar'; -import ConfigProvider from '../../../config-provider'; -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; -import '../../../demo-helper/style'; -import '../../style'; - -const i18nMap = { - 'zh-cn': { - dateFullscreenCalendar: '全屏日历', - cardCalendar: '卡片日历', - panelCalendar: '面板日历', - rangeCalendar: '多面板日历', - - date: '日', - month: '月', - year: '年', - - normal: '普通', - }, - 'en-us': { - dateFullscreenCalendar: 'Fullscreen', - cardCalendar: 'Card', - panelCalendar: 'Panel', - rangeCalendar: 'Range Panel', - - date: 'Day', - month: 'Month', - year: 'Year', - - normal: 'Normal', - } -}; - -const wrappedCalendarStyle = { - width: '320px', - overflow: 'hidden', -}; - -const wrappedRangeCalendarStyle = { - width: '600px', - overflow: 'hidden' -}; - -window.renderDemo = function(lang = 'en-us') { - moment.locale(lang); - render(i18nMap[lang], lang); -}; - -/* eslint-disable */ -function render(i18n, lang) { - const currentDate = moment(); - const calendarValue = currentDate.clone().add(1, 'days'); - - const disabledDate = function (date) { - return date.valueOf() > currentDate.clone().add(3, 'days').valueOf(); - }; - - return ReactDOM.render( - -
    - - - - - - - - - - - - - - - - -
    - -
    -
    -
    - - -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    - - - -
    - -
    -
    -
    - - - -
    - -
    -
    -
    -
    - - - - -
    - -
    -
    -
    -
    -
    -
    , document.getElementById('container')); -} - -renderDemo(); - -initDemo('calendar'); diff --git a/components/calendar/__docs__/theme/index.tsx b/components/calendar/__docs__/theme/index.tsx new file mode 100644 index 0000000000..c09bd028fe --- /dev/null +++ b/components/calendar/__docs__/theme/index.tsx @@ -0,0 +1,176 @@ +import moment, { type Moment } from 'moment'; +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; +import Calendar from '../../index'; +import RangeCalendar from '../../range-calendar'; +import ConfigProvider from '../../../config-provider'; +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; +import '../../../demo-helper/style'; +import '../../style'; + +const i18nMap = { + 'zh-cn': { + dateFullscreenCalendar: '全屏日历', + cardCalendar: '卡片日历', + panelCalendar: '面板日历', + rangeCalendar: '多面板日历', + + date: '日', + month: '月', + year: '年', + + normal: '普通', + }, + 'en-us': { + dateFullscreenCalendar: 'Fullscreen', + cardCalendar: 'Card', + panelCalendar: 'Panel', + rangeCalendar: 'Range Panel', + + date: 'Day', + month: 'Month', + year: 'Year', + + normal: 'Normal', + }, +}; + +const wrappedCalendarStyle = { + width: '320px', + overflow: 'hidden', +}; + +const wrappedRangeCalendarStyle = { + width: '600px', + overflow: 'hidden', +}; + +function render(i18n: any, lang: string) { + const currentDate = moment(); + const calendarValue = currentDate.clone().add(1, 'days'); + + const disabledDate = function (date: Moment) { + return date.valueOf() > currentDate.clone().add(3, 'days').valueOf(); + }; + + ReactDOM.render( + +
    + + + + + + + + + + + + + + + + +
    + +
    +
    +
    + + +
    + +
    +
    +
    +
    + + + + +
    + +
    +
    +
    + + + +
    + +
    +
    +
    + + + +
    + +
    +
    +
    +
    + + + + +
    + +
    +
    +
    +
    +
    +
    , + document.getElementById('container') + ); +} + +window.renderDemo = function (lang = 'en-us') { + moment.locale(lang); + render(i18nMap[lang], lang); +}; + +renderDemo(); + +initDemo('calendar'); diff --git a/components/calendar/__tests__/a11y-spec.js b/components/calendar/__tests__/a11y-spec.js deleted file mode 100644 index d013f4fed5..0000000000 --- a/components/calendar/__tests__/a11y-spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Calendar from '../index'; -import '../style'; -import { afterEach as a11yAfterEach, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Calendar A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - a11yAfterEach(); - }); - - // TODO Select support a11y - it.skip('should not have any violations when default', async () => { - wrapper = await testReact(); - return wrapper; - }); - // TODO Select support a11y - it.skip('should not have any violations when shape', async () => { - wrapper = await testReact( -
    - - - -
    - ); - return wrapper; - }); -}); diff --git a/components/calendar/__tests__/a11y-spec.tsx b/components/calendar/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..e65646af1f --- /dev/null +++ b/components/calendar/__tests__/a11y-spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Calendar from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Calendar A11y', () => { + it('should not have any violations when default', async () => { + await testReact(); + }); + it('should not have any violations when shape', async () => { + await testReact( +
    + + + +
    + ); + }); +}); diff --git a/components/calendar/__tests__/index-spec.js b/components/calendar/__tests__/index-spec.js deleted file mode 100644 index 3289961186..0000000000 --- a/components/calendar/__tests__/index-spec.js +++ /dev/null @@ -1,452 +0,0 @@ -import React from 'react'; -import sinon from 'sinon'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import moment from 'moment'; -import Calendar from '../index'; -import RangeCalendar from '../range-calendar'; -import '../style'; -import { getLocaleData } from '../utils/index'; - -Enzyme.configure({ - adapter: new Adapter(), -}); -moment.locale('zh-cn'); -const defaultVal = moment('2017-10-01', 'YYYY-MM-DD', true); - -/* eslint-disable */ -describe('Calendar', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - describe('render', () => { - it('should render calendar', () => { - wrapper = mount(); - assert(wrapper.find('.next-calendar.next-calendar-fullscreen').length === 1); - }); - - it('should render with defaultVisibleMonth', () => { - wrapper = mount( defaultVal} />); - assert(wrapper.find('td[title="2017-10-01"]').length === 1); - }); - - it('should render with default value', () => { - wrapper = mount( defaultVal} defaultValue={defaultVal} />); - assert(wrapper.find('td[title="2017-10-01"]').hasClass('next-selected')); - }); - - it('should render calendar panel', () => { - wrapper = mount(); - assert(wrapper.find('.next-calendar-panel-header').length === 1); - }); - - it('should render calendar card', () => { - wrapper = mount(); - assert(wrapper.find('.next-calendar-card').length === 1); - }); - - it('should render uncontrolled calendar', () => { - wrapper = mount(); - assert(wrapper.find('td[title="2017-10-01"]').hasClass('next-selected')); - assert(wrapper.find('td[title="2017-10-02"]').length); - wrapper.find('td[title="2017-10-02"]').simulate('click'); - assert(wrapper.find('td[title="2017-10-02"]').hasClass('next-selected')); - }); - - it('should render controlled calendar', () => { - wrapper = mount( defaultVal} />); - assert(wrapper.find('td[title="2017-10-01"]').hasClass('next-selected')); - wrapper.setProps({ value: defaultVal.clone().add(1, 'days') }); - assert(wrapper.find('td[title="2017-10-02"]').hasClass('next-selected')); - }); - - it('should render controlled calendar with mode', () => { - wrapper = mount(); - wrapper.setProps({ mode: 'month' }); - assert(wrapper.find('.next-calendar-cell').length === 12); - }); - - it('should render with disabled dates', () => { - const disabledDate = (date, view) => { - assert(view === 'date'); - return date.valueOf() > defaultVal.valueOf(); - }; - wrapper = mount( defaultVal} disabledDate={disabledDate} />); - assert(wrapper.find('td[title="2017-10-02"]').hasClass('next-disabled')); - }); - - it('should render custom content', () => { - const dateCellRender = date => { - const dateNum = date.date(); - if (defaultVal.month() !== date.month()) { - return dateNum; - } - - if (dateNum === 1) { - return
    hello world
    ; - } - }; - wrapper = mount( defaultVal} dateCellRender={dateCellRender} />); - assert(wrapper.find('td[title="2017-10-01"] div.test').length === 1); - }); - - it('should render custom format 0.x', () => { - const locale = { - format: { - months: [ - '一月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '十一月', - '十二月', - ], - shortMonths: [ - '一月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '十一月', - '十二月', - ], - weekdays: ['星期天', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'], - shortWeekdays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], - veryShortWeekdays: ['日', '一', '二', '三', '四', '五', '六'], - ampms: ['上午', '下午'], - }, - }; - wrapper = mount(); - - const localeData = getLocaleData(locale.format, moment().localeData()); - assert(localeData.monthsShort() === locale.format.shortMonths); - assert(localeData.months() === locale.format.months); - assert( - localeData.firstDayOfWeek() === - moment() - .localeData() - .firstDayOfWeek() - ); - assert(localeData.weekdays() === locale.format.weekdays); - assert(localeData.weekdaysShort() === locale.format.shortWeekdays); - assert(localeData.weekdaysMin() === locale.format.veryShortWeekdays); - - assert( - wrapper - .find('.next-calendar-th') - .at(0) - .text() === - locale.format.shortWeekdays[ - moment() - .localeData() - .firstDayOfWeek() - ] - ); - }); - }); - - describe('action', () => { - it('should change mode', () => { - const onModeChange = sinon.spy(); - - wrapper = mount(); - wrapper - .find('.next-radio-wrapper input') - .at(1) - .simulate('change', { target: { checked: true } }); - assert(wrapper.find('td').length === 12); - assert(wrapper.find('td[title="1月"]').length === 1); - assert(onModeChange.calledOnce); - }); - - it('should change panel mode to month', () => { - wrapper = mount( defaultVal} />); - wrapper - .find('.next-calendar-btn') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-calendar-month').length === 12); - wrapper - .find('.next-calendar-btn') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2010-2019"]').length === 1); - }); - - it('should change panel mode to year', () => { - wrapper = mount(); - wrapper - .find('.next-calendar-btn') - .at(3) - .simulate('click'); - assert(wrapper.find('.next-calendar-year').length === 12); - }); - - it('should change visible month', () => { - wrapper = mount( defaultVal} />); - wrapper.find('.next-calendar-btn-prev-month').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="九月"]').length === 1); - wrapper.find('.next-calendar-btn-next-month').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="十月"]').length === 1); - }); - - it('should change visible month by year', () => { - wrapper = mount( defaultVal} />); - wrapper.find('.next-calendar-btn-prev-year').simulate('click'); - wrapper.find('.next-calendar-btn-next-year').simulate('click'); - assert( - wrapper - .find('.next-calendar-btn') - .at(3) - .instance().title === '2017' - ); - }); - - it('should change decade', () => { - wrapper = mount( defaultVal} />); - wrapper.find('.next-calendar-btn-prev-decade').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2000-2009"]').length === 1); - wrapper.find('.next-calendar-btn-next-decade').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2010-2019"]').length === 1); - }); - - it('should select date', () => { - const onSelect = val => { - assert(val.format('YYYY-MM-DD') === '2017-10-02'); - }; - wrapper = mount( defaultVal} onSelect={onSelect} />); - wrapper.find('td[title="2017-10-02"]').simulate('click'); - }); - - it('should hide cell for other month', () => { - let isClicked = false; - const onSelect = val => { - // handle click from this month - assert(val.format('YYYY-MM-DD') === '2017-10-02'); - isClicked = true; - }; - wrapper = mount( - defaultVal} onSelect={onSelect} /> - ); - - // hide cell for other month - assert(wrapper.find('.next-calendar-cell-next-month[title="2017-11-01"]').text() === ''); - wrapper.find('td[title="2017-10-02"]').simulate('click'); - assert(isClicked === true); - }); - - it('should block click event from other month', () => { - let isClicked = false; - wrapper = mount( - defaultVal} - onSelect={() => { - isClicked = true; - }} - /> - ); - - wrapper.find('.next-calendar-cell-next-month[title="2017-11-01"]').simulate('click'); - assert(isClicked === false); - }); - }); -}); - -describe('RangeCalendar', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - } - wrapper = null; - }); - - describe('render', () => { - it('should render RangeCalendar', () => { - wrapper = mount(); - assert(wrapper.find('.next-calendar-table').length === 2); - }); - - it('should render with defaultStartValue & defaultEndValue', () => { - wrapper = mount( - defaultVal} - defaultStartValue={defaultVal} - defaultEndValue={defaultVal.clone().add(1, 'months')} - /> - ); - assert(wrapper.find('td[title="2017-10-01"]').hasClass('next-selected')); - assert(wrapper.find('td[title="2017-10-15"]').hasClass('next-inrange')); - assert( - wrapper - .find('td[title="2017-11-01"]') - .at(1) - .hasClass('next-selected') - ); - }); - - it('should render with controlled value', () => { - wrapper = mount( - defaultVal} - startValue={defaultVal} - endValue={defaultVal.clone().add(1, 'months')} - /> - ); - wrapper.setProps({ - startValue: defaultVal.clone().add(2, 'days'), - endValue: defaultVal.clone().add(1, 'months'), - }); - assert(wrapper.find('td[title="2017-10-03"]').hasClass('next-selected')); - assert(wrapper.find('td[title="2017-10-15"]').hasClass('next-inrange')); - assert( - wrapper - .find('td[title="2017-11-01"]') - .at(1) - .hasClass('next-selected') - ); - }); - - it('should render custom format 0.x', () => { - const locale = { - format: { - months: [ - '一月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '十一月', - '十二月', - ], - shortMonths: [ - '一月', - '二月', - '三月', - '四月', - '五月', - '六月', - '七月', - '八月', - '九月', - '十月', - '十一月', - '十二月', - ], - weekdays: ['星期天', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'], - shortWeekdays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], - veryShortWeekdays: ['日', '一', '二', '三', '四', '五', '六'], - ampms: ['上午', '下午'], - }, - }; - wrapper = mount(); - - assert( - wrapper - .find('.next-calendar-th') - .at(0) - .text() === - locale.format.shortWeekdays[ - moment() - .localeData() - .firstDayOfWeek() - ] - ); - }); - }); - - describe('action', () => { - it('should change to month mode in panel', () => { - wrapper = mount( - defaultVal} - defaultStartValue={defaultVal} - defaultEndValue={defaultVal.clone().add(1, 'months')} - /> - ); - wrapper - .find('.next-calendar-btn') - .at(2) - .simulate('click'); - assert(wrapper.find('td[title="10月"]').hasClass('next-selected')); - wrapper.find('td[title="10月"]').simulate('click'); - wrapper - .find('.next-calendar-btn') - .at(4) - .simulate('click'); - assert(wrapper.find('td[title="10月"]').hasClass('next-selected')); - }); - - it('should change to year mode in panel', () => { - wrapper = mount( - defaultVal} - defaultStartValue={defaultVal} - defaultEndValue={defaultVal.clone().add(1, 'months')} - /> - ); - wrapper - .find('.next-calendar-btn') - .at(3) - .simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2010-2019"]').length === 1); - }); - - it('should change visible month', () => { - wrapper = mount( defaultVal} />); - wrapper.find('.next-calendar-btn-prev-month').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="九月"]').length === 1); - wrapper.find('.next-calendar-btn-next-month').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="十月"]').length === 1); - }); - - it('should change visible month by year', () => { - wrapper = mount( defaultVal} />); - wrapper.find('.next-calendar-btn-prev-year').simulate('click'); - wrapper.find('.next-calendar-btn-next-year').simulate('click'); - assert( - wrapper - .find('.next-calendar-btn') - .at(3) - .instance().title === '2017' - ); - }); - - it('should change decade', () => { - wrapper = mount( defaultVal} />); - wrapper - .find('.next-calendar-btn') - .at(3) - .simulate('click'); - wrapper.find('.next-calendar-btn-prev-decade').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2000-2009"]').length === 1); - wrapper.find('.next-calendar-btn-next-decade').simulate('click'); - assert(wrapper.find('.next-calendar-panel-header button[title="2010-2019"]').length === 1); - }); - }); -}); diff --git a/components/calendar/__tests__/index-spec.tsx b/components/calendar/__tests__/index-spec.tsx new file mode 100644 index 0000000000..c5624d51b3 --- /dev/null +++ b/components/calendar/__tests__/index-spec.tsx @@ -0,0 +1,412 @@ +import React from 'react'; +import moment from 'moment'; +import 'moment/locale/zh-cn'; +import Calendar, { type CalendarProps } from '../index'; +import RangeCalendar from '../range-calendar'; +import '../style'; +import { getLocaleData } from '../utils/index'; + +moment.locale('zh-cn'); +const defaultVal = moment('2017-10-01', 'YYYY-MM-DD', true); + +describe('Calendar', () => { + describe('render', () => { + it('should render calendar', () => { + cy.mount(); + cy.get('.next-calendar.next-calendar-fullscreen').should('have.length', 1); + }); + + it('should render with defaultVisibleMonth', () => { + cy.mount( defaultVal} />); + cy.get('td[title="2017-10-01"]').should('have.length', 1); + }); + + it('should render with default value', () => { + cy.mount( defaultVal} defaultValue={defaultVal} />); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-selected'); + }); + + it('should render calendar panel', () => { + cy.mount(); + cy.get('.next-calendar-panel-header').should('have.length', 1); + }); + + it('should render calendar card', () => { + cy.mount(); + cy.get('.next-calendar-card').should('have.length', 1); + }); + + it('should render uncontrolled calendar', () => { + cy.mount(); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-selected'); + cy.get('td[title="2017-10-02"]').should('exist'); + cy.get('td[title="2017-10-02"]').click(); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-selected'); + }); + + it('should render controlled calendar', () => { + cy.mount( defaultVal} />).as( + 'Demo' + ); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-selected'); + cy.rerender('Demo', { value: defaultVal.clone().add(1, 'days') }); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-selected'); + }); + + it('should render controlled calendar with mode', () => { + cy.mount().as('Demo'); + cy.rerender('Demo', { mode: 'month' }); + cy.get('.next-calendar-cell').should('have.length', 12); + }); + + it('should render with disabled dates', () => { + const disabledDateHandler = cy.spy().as('disabledDateHandler'); + const disabledDate: CalendarProps['disabledDate'] = (date, view) => { + disabledDateHandler(view); + return date.valueOf() > defaultVal.valueOf(); + }; + cy.mount( + defaultVal} disabledDate={disabledDate} /> + ); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-disabled'); + cy.get('@disabledDateHandler').should('be.calledWith', 'date'); + }); + + it('should render custom content', () => { + const dateCellRender: CalendarProps['dateCellRender'] = date => { + const dateNum = date.date(); + if (defaultVal.month() !== date.month()) { + return dateNum; + } + + if (dateNum === 1) { + return
    hello world
    ; + } + }; + cy.mount( + defaultVal} dateCellRender={dateCellRender} /> + ); + cy.get('td[title="2017-10-01"] div.test').should('have.length', 1); + }); + + it('should render custom format 0.x', () => { + const locale = { + format: { + months: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + shortMonths: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + weekdays: [ + '星期天', + '星期一', + '星期二', + '星期三', + '星期四', + '星期五', + '星期六', + ], + shortWeekdays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + veryShortWeekdays: ['日', '一', '二', '三', '四', '五', '六'], + ampms: ['上午', '下午'], + }, + }; + cy.mount(); + + const localeData = getLocaleData(locale.format, moment().localeData()); + cy.wrap(localeData.monthsShort()).should('equal', locale.format.shortMonths); + cy.wrap(localeData.months()).should('equal', locale.format.months); + cy.wrap(localeData.firstDayOfWeek()).should( + 'equal', + moment().localeData().firstDayOfWeek() + ); + cy.wrap(localeData.weekdays()).should('equal', locale.format.weekdays); + cy.wrap(localeData.weekdaysShort()).should('equal', locale.format.shortWeekdays); + cy.wrap(localeData.weekdaysMin()).should('equal', locale.format.veryShortWeekdays); + cy.get('.next-calendar-th') + .eq(0) + .should( + 'have.text', + locale.format.shortWeekdays[moment().localeData().firstDayOfWeek()] + ); + }); + }); + + describe('action', () => { + it('should change mode', () => { + const onModeChange = cy.spy().as('onModeChange'); + cy.mount(); + cy.get('.next-radio-wrapper input').eq(1).check({ force: true }); + cy.get('td').should('have.length', 12); + cy.get('td[title="1月"]').should('have.length', 1); + cy.get('@onModeChange').should('be.calledOnce'); + }); + + it('should change panel mode to month', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn').eq(2).click(); + cy.get('.next-calendar-month').should('have.length', 12); + cy.get('.next-calendar-btn').eq(1).click(); + cy.get('.next-calendar-panel-header button[title="2010-2019"]').should( + 'have.length', + 1 + ); + }); + + it('should change panel mode to year', () => { + cy.mount(); + cy.get('.next-calendar-btn').eq(3).click(); + cy.get('.next-calendar-year').should('have.length', 12); + }); + + it('should change visible month', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn-prev-month').click(); + cy.get('.next-calendar-panel-header button[title="九月"]').should('have.length', 1); + cy.get('.next-calendar-btn-next-month').click(); + cy.get('.next-calendar-panel-header button[title="十月"]').should('have.length', 1); + }); + + it('should change visible month by year', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn-prev-year').click(); + cy.get('.next-calendar-btn').eq(3).should('have.attr', 'title', '2016'); + cy.get('.next-calendar-btn-next-year').click(); + cy.get('.next-calendar-btn').eq(3).should('have.attr', 'title', '2017'); + }); + + it('should change decade', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn-prev-decade').click(); + cy.get('.next-calendar-panel-header button[title="2000-2009"]').should( + 'have.length', + 1 + ); + cy.get('.next-calendar-btn-next-decade').click(); + cy.get('.next-calendar-panel-header button[title="2010-2019"]').should( + 'have.length', + 1 + ); + }); + + it('should select date', () => { + const onSelectHandler = cy.spy().as('onSelectHandler'); + const onSelect: CalendarProps['onSelect'] = val => { + onSelectHandler(val.format('YYYY-MM-DD')); + }; + cy.mount( + defaultVal} + onSelect={onSelect} + /> + ); + cy.get('td[title="2017-10-02"]').click(); + cy.get('@onSelectHandler').should('be.calledWith', '2017-10-02'); + }); + + it('should hide cell for other month', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-cell-next-month[title="2017-11-01"]').should('have.text', ''); + }); + + it('should block click event from other month', () => { + cy.mount( + defaultVal} + onSelect={cy.spy().as('onSelect')} + /> + ); + + cy.get('.next-calendar-cell-next-month[title="2017-11-01"]').click(); + cy.get('@onSelect').should('not.be.called'); + }); + }); +}); + +describe('RangeCalendar', () => { + describe('render', () => { + it('should render RangeCalendar', () => { + cy.mount(); + cy.get('.next-calendar-table').should('have.length', 2); + // assert(wrapper.find('.next-calendar-table').length === 2); + }); + + it('should render with defaultStartValue & defaultEndValue', () => { + cy.mount( + defaultVal} + defaultStartValue={defaultVal} + defaultEndValue={defaultVal.clone().add(1, 'months')} + /> + ); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-selected'); + cy.get('td[title="2017-10-15"]').should('have.class', 'next-inrange'); + cy.get('td[title="2017-11-01"]').should('have.class', 'next-selected'); + }); + + it('should render with controlled value', () => { + cy.mount( + defaultVal} + startValue={defaultVal} + endValue={defaultVal.clone().add(1, 'months')} + /> + ).as('Demo'); + cy.rerender('Demo', { + startValue: defaultVal.clone().add(2, 'days'), + endValue: defaultVal.clone().add(1, 'months'), + }); + cy.get('td[title="2017-10-03"]').should('have.class', 'next-selected'); + cy.get('td[title="2017-10-15"]').should('have.class', 'next-inrange'); + cy.get('td[title="2017-11-01"]').should('have.class', 'next-selected'); + }); + + it('should render custom format 0.x', () => { + const locale = { + format: { + months: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + shortMonths: [ + '一月', + '二月', + '三月', + '四月', + '五月', + '六月', + '七月', + '八月', + '九月', + '十月', + '十一月', + '十二月', + ], + weekdays: [ + '星期天', + '星期一', + '星期二', + '星期三', + '星期四', + '星期五', + '星期六', + ], + shortWeekdays: ['周日', '周一', '周二', '周三', '周四', '周五', '周六'], + veryShortWeekdays: ['日', '一', '二', '三', '四', '五', '六'], + ampms: ['上午', '下午'], + }, + }; + cy.mount(); + cy.get('.next-calendar-th') + .eq(0) + .should( + 'have.text', + locale.format.shortWeekdays[moment().localeData().firstDayOfWeek()] + ); + }); + }); + + describe('action', () => { + it('should change to month mode in panel', () => { + cy.mount( + defaultVal} + defaultStartValue={defaultVal} + defaultEndValue={defaultVal.clone().add(1, 'months')} + /> + ); + cy.get('.next-calendar-btn').eq(2).click(); + cy.get('td[title="10月"]').should('have.class', 'next-selected'); + cy.get('td[title="10月').click(); + cy.get('.next-calendar-btn').eq(4).click(); + cy.get('td[title="10月"]').should('have.class', 'next-selected'); + }); + + it('should change to year mode in panel', () => { + cy.mount( + defaultVal} + defaultStartValue={defaultVal} + defaultEndValue={defaultVal.clone().add(1, 'months')} + /> + ); + cy.get('.next-calendar-btn').eq(3).click(); + cy.get('.next-calendar-panel-header button[title="2010-2019"]').should( + 'have.length', + 1 + ); + }); + + it('should change visible month', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn-prev-month').click(); + cy.get('.next-calendar-panel-header button[title="九月"]').should('have.length', 1); + cy.get('.next-calendar-btn-next-month').click(); + cy.get('.next-calendar-panel-header button[title="十月"]').should('have.length', 1); + }); + + it('should change visible month by year', () => { + cy.mount( defaultVal} />); + cy.get('.next-calendar-btn-prev-year').click(); + cy.get('.next-calendar-btn').eq(3).should('have.attr', 'title', '2016'); + cy.get('.next-calendar-btn-next-year').click(); + cy.get('.next-calendar-btn').eq(3).should('have.attr', 'title', '2017'); + }); + + it('should change decade', () => { + cy.mount( + defaultVal} + /> + ); + cy.get('.next-calendar-btn').eq(3).click(); + cy.get('.next-calendar-btn-prev-decade').click(); + cy.get('.next-calendar-panel-header button[title="2000-2009"]').should( + 'have.length', + 1 + ); + cy.get('.next-calendar-btn-next-decade').click(); + cy.get('.next-calendar-panel-header button[title="2010-2019"]').should( + 'have.length', + 1 + ); + }); + }); +}); diff --git a/components/calendar/__tests__/issue-spec.tsx b/components/calendar/__tests__/issue-spec.tsx new file mode 100644 index 0000000000..83ec6b448f --- /dev/null +++ b/components/calendar/__tests__/issue-spec.tsx @@ -0,0 +1,50 @@ +import React from 'react'; +import moment from 'moment'; +import Calendar from '../index'; +import '../style'; + +moment.locale('zh-cn'); +const defaultVal = moment('2024-05-13', 'YYYY-MM-DD', true); + +describe('Calendar issues', () => { + // Fix: https://github.com/alibaba-fusion/next/issues/4782 + describe('should fix #4782', () => { + it('should not have switch button when shape is panel and showOtherMonth is false', () => { + cy.mount(); + cy.get('.next-calendar-panel-header > button').should('have.length', 0); + }); + it('should not have mode switch button when shape is fullscreen and showOtherMonth is false', () => { + cy.mount(); + cy.get('.next-radio-group').should('have.length', 0); + }); + + describe('action', () => { + it('should not change mode to month when showOtherMonth is false and shape is panel', () => { + cy.mount(); + cy.get('.next-calendar-btn').eq(0).click(); + cy.get('.next-calendar-month').should('have.length', 0); + }); + + it('should not change mode to year when showOtherMonth is false and shape is panel', () => { + cy.mount(); + cy.get('.next-calendar-btn').eq(1).click(); + cy.get('.next-calendar-year').should('have.length', 0); + }); + + it('should not change year when showOtherMonth is false and shape is fullscreen', () => { + cy.mount( + + ); + cy.get('.next-select').eq(0).click(); + cy.get('.next-menu-item').should('have.length', 0); + }); + it('should not change month when showOtherMonth is false and shape is card', () => { + cy.mount( + + ); + cy.get('.next-select').eq(1).click(); + cy.get('.next-menu-item').should('have.length', 0); + }); + }); + }); +}); diff --git a/components/calendar/calendar.jsx b/components/calendar/calendar.jsx deleted file mode 100644 index ee4c055043..0000000000 --- a/components/calendar/calendar.jsx +++ /dev/null @@ -1,351 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import moment from 'moment'; -import classnames from 'classnames'; -import ConfigProvider from '../config-provider'; -import nextLocale from '../locale/zh-cn'; -import { func, obj } from '../util'; -import CardHeader from './head/card-header'; -import DatePanelHeader from './head/date-panel-header'; -import MonthPanelHeader from './head/month-panel-header'; -import YearPanelHeader from './head/year-panel-header'; -import DateTable from './table/date-table'; -import MonthTable from './table/month-table'; -import YearTable from './table/year-table'; -import { - checkMomentObj, - formatDateValue, - getVisibleMonth, - isSameYearMonth, - CALENDAR_MODES, - CALENDAR_MODE_DATE, - CALENDAR_MODE_MONTH, - CALENDAR_MODE_YEAR, - getLocaleData, -} from './utils'; - -const isValueChanged = (value, oldVlaue) => { - if (value && oldVlaue) { - if (!moment.isMoment(value)) { - value = moment(value); - } - if (!moment.isMoment(oldVlaue)) { - oldVlaue = moment(oldVlaue); - } - return value.valueOf() !== oldVlaue.valueOf(); - } else { - return value !== oldVlaue; - } -}; - -/** Calendar */ -class Calendar extends Component { - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 默认选中的日期(moment 对象) - */ - defaultValue: checkMomentObj, - /** - * 选中的日期值 (moment 对象) - */ - value: checkMomentObj, - /** - * 面板模式 - */ - mode: PropTypes.oneOf(CALENDAR_MODES), // 生成 API 文档需要手动改回 ['date', 'month', 'year'] - // 面板可变化的模式列表,仅初始化时接收一次 - modes: PropTypes.array, - // 禁用更改面板模式,采用 dropdown 的方式切换显示日期 (暂不正式对外透出) - disableChangeMode: PropTypes.bool, - // 日期值的格式(用于日期title显示的格式) - format: PropTypes.string, - /** - * 是否展示非本月的日期 - */ - showOtherMonth: PropTypes.bool, - /** - * 默认展示的月份 - */ - defaultVisibleMonth: PropTypes.func, - /** - * 展现形态 - */ - shape: PropTypes.oneOf(['card', 'fullscreen', 'panel']), - /** - * 选择日期单元格时的回调 - * @param {Object} value 对应的日期值 (moment 对象) - */ - onSelect: PropTypes.func, - /** - * 面板模式变化时的回调 - * @param {String} mode 对应面板模式 date month year - */ - onModeChange: PropTypes.func, - /** - * 展现的月份变化时的回调 - * @param {Object} value 显示的月份 (moment 对象) - * @param {String} reason 触发月份改变原因 - */ - onVisibleMonthChange: PropTypes.func, - /** - * 自定义样式类 - */ - className: PropTypes.string, - /** - * 自定义日期渲染函数 - * @param {Object} value 日期值(moment对象) - * @returns {ReactNode} - */ - dateCellRender: PropTypes.func, - /** - * 自定义月份渲染函数 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @returns {ReactNode} - */ - monthCellRender: PropTypes.func, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - /** - * 年份范围,[START_YEAR, END_YEAR] (只在shape 为 ‘card’, 'fullscreen' 下生效) - */ - yearRange: PropTypes.arrayOf(PropTypes.number), - /** - * 不可选择的日期 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @returns {Boolean} - */ - disabledDate: PropTypes.func, - /** - * 国际化配置 - */ - locale: PropTypes.object, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - shape: 'fullscreen', - modes: CALENDAR_MODES, - disableChangeMode: false, - format: 'YYYY-MM-DD', - onSelect: func.noop, - onVisibleMonthChange: func.noop, - onModeChange: func.noop, - dateCellRender: value => value.date(), - locale: nextLocale.Calendar, - showOtherMonth: true, - }; - - constructor(props, context) { - super(props, context); - const value = formatDateValue(props.value || props.defaultValue); - const visibleMonth = getVisibleMonth(props.defaultVisibleMonth, value); - - this.MODES = props.modes; - this.today = moment(); - this.state = { - value, - mode: props.mode || this.MODES[0], - MODES: this.MODES, - visibleMonth, - }; - } - - static getDerivedStateFromProps(props, state) { - const st = {}; - if ('value' in props) { - const value = formatDateValue(props.value); - if (value && isValueChanged(props.value, state.value)) { - st.visibleMonth = value; - } - st.value = value; - } - - if (props.mode && state.MODES.indexOf(props.mode) > -1) { - st.mode = props.mode; - } - - return st; - } - - onSelectCell = (date, nextMode) => { - const { visibleMonth } = this.state; - const { shape, showOtherMonth } = this.props; - - // 点击其他月份日期不生效 - if (!showOtherMonth && !isSameYearMonth(visibleMonth, date)) { - return; - } - - this.changeVisibleMonth(date, 'cellClick'); - - if (!('value' in this.props)) { - // 非受控模式,直接修改当前state - this.setState({ - value: date, - }); - } - - // 当用户所在的面板为初始化面板时,则选择动作为触发 onSelect 回调 - if (this.state.mode === this.MODES[0]) { - this.props.onSelect(date); - } - - if (shape === 'panel') { - this.changeMode(nextMode); - } - }; - - changeMode = nextMode => { - if (nextMode && this.MODES.indexOf(nextMode) > -1 && nextMode !== this.state.mode) { - this.setState({ mode: nextMode }); - this.props.onModeChange(nextMode); - } - }; - - changeVisibleMonth = (date, reason) => { - if (!isSameYearMonth(date, this.state.visibleMonth)) { - this.setState({ visibleMonth: date }); - this.props.onVisibleMonthChange(date, reason); - } - }; - - /** - * 根据日期偏移量设置当前展示的月份 - * @param {Number} offset 日期偏移的数量 - * @param {String} type 日期偏移的类型 days, months, years - */ - changeVisibleMonthByOffset(offset, type) { - const cloneValue = this.state.visibleMonth.clone(); - cloneValue.add(offset, type); - this.changeVisibleMonth(cloneValue, 'buttonClick'); - } - - goPrevDecade = () => { - this.changeVisibleMonthByOffset(-10, 'years'); - }; - - goNextDecade = () => { - this.changeVisibleMonthByOffset(10, 'years'); - }; - - goPrevYear = () => { - this.changeVisibleMonthByOffset(-1, 'years'); - }; - - goNextYear = () => { - this.changeVisibleMonthByOffset(1, 'years'); - }; - - goPrevMonth = () => { - this.changeVisibleMonthByOffset(-1, 'months'); - }; - - goNextMonth = () => { - this.changeVisibleMonthByOffset(1, 'months'); - }; - - render() { - const { - prefix, - rtl, - className, - shape, - showOtherMonth, - format, - locale, - dateCellRender, - monthCellRender, - yearCellRender, - disabledDate, - yearRange, - disableChangeMode, - ...others - } = this.props; - const state = this.state; - - const classNames = classnames( - { - [`${prefix}calendar`]: true, - [`${prefix}calendar-${shape}`]: shape, - }, - className - ); - - if (rtl) { - others.dir = 'rtl'; - } - - const visibleMonth = state.visibleMonth; - - // reset moment locale - if (locale.momentLocale) { - state.value && state.value.locale(locale.momentLocale); - visibleMonth.locale(locale.momentLocale); - } - - const localeData = getLocaleData(locale.format || {}, visibleMonth.localeData()); - - const headerProps = { - prefix, - value: state.value, - mode: state.mode, - disableChangeMode, - yearRange, - locale, - rtl, - visibleMonth, - momentLocale: localeData, - changeMode: this.changeMode, - changeVisibleMonth: this.changeVisibleMonth, - goNextDecade: this.goNextDecade, - goNextYear: this.goNextYear, - goNextMonth: this.goNextMonth, - goPrevDecade: this.goPrevDecade, - goPrevYear: this.goPrevYear, - goPrevMonth: this.goPrevMonth, - }; - - const tableProps = { - prefix, - visibleMonth, - showOtherMonth, - value: state.value, - mode: state.mode, - locale, - dateCellRender, - monthCellRender, - yearCellRender, - disabledDate, - momentLocale: localeData, - today: this.today, - goPrevDecade: this.goPrevDecade, - goNextDecade: this.goNextDecade, - }; - - const tables = { - [CALENDAR_MODE_DATE]: , - [CALENDAR_MODE_MONTH]: , - [CALENDAR_MODE_YEAR]: , - }; - - const panelHeaders = { - [CALENDAR_MODE_DATE]: , - [CALENDAR_MODE_MONTH]: , - [CALENDAR_MODE_YEAR]: , - }; - - return ( -
    - {shape === 'panel' ? panelHeaders[state.mode] : } - {tables[state.mode]} -
    - ); - } -} - -export default polyfill(Calendar); diff --git a/components/calendar/calendar.tsx b/components/calendar/calendar.tsx new file mode 100644 index 0000000000..e55b78cea8 --- /dev/null +++ b/components/calendar/calendar.tsx @@ -0,0 +1,311 @@ +import React, { Component, type MouseEvent } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import moment, { type MomentInput, type Moment } from 'moment'; +import classnames from 'classnames'; +import ConfigProvider from '../config-provider'; +import nextLocale from '../locale/zh-cn'; +import { type ClassPropsWithDefault, func, obj } from '../util'; +import CardHeader from './head/card-header'; +import DatePanelHeader from './head/date-panel-header'; +import MonthPanelHeader from './head/month-panel-header'; +import YearPanelHeader from './head/year-panel-header'; +import DateTable from './table/date-table'; +import MonthTable from './table/month-table'; +import YearTable from './table/year-table'; +import { + checkMomentObj, + formatDateValue, + getVisibleMonth, + isSameYearMonth, + CALENDAR_MODES, + CALENDAR_MODE_DATE, + CALENDAR_MODE_MONTH, + CALENDAR_MODE_YEAR, + getLocaleData, +} from './utils'; +import type { CalendarMode, CalendarProps, CalendarState, VisibleMonthChangeType } from './types'; + +const isValueChanged = (value: MomentInput, oldValue: MomentInput) => { + if (value && oldValue) { + if (!moment.isMoment(value)) { + value = moment(value); + } + if (!moment.isMoment(oldValue)) { + oldValue = moment(oldValue); + } + return value.valueOf() !== oldValue.valueOf(); + } else { + return value !== oldValue; + } +}; + +type InnerCalendarProps = ClassPropsWithDefault; + +/** Calendar */ +class Calendar extends Component { + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + defaultValue: checkMomentObj, + value: checkMomentObj, + mode: PropTypes.oneOf(CALENDAR_MODES), + modes: PropTypes.array, + disableChangeMode: PropTypes.bool, + format: PropTypes.string, + showOtherMonth: PropTypes.bool, + defaultVisibleMonth: PropTypes.func, + shape: PropTypes.oneOf(['card', 'fullscreen', 'panel']), + onSelect: PropTypes.func, + onModeChange: PropTypes.func, + onVisibleMonthChange: PropTypes.func, + className: PropTypes.string, + dateCellRender: PropTypes.func, + monthCellRender: PropTypes.func, + yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender + yearRange: PropTypes.arrayOf(PropTypes.number), + disabledDate: PropTypes.func, + locale: PropTypes.object, + onChange: PropTypes.func, + }; + + static defaultProps: CalendarProps = { + prefix: 'next-', + rtl: false, + shape: 'fullscreen', + modes: CALENDAR_MODES, + disableChangeMode: false, + format: 'YYYY-MM-DD', + onSelect: func.noop, + onVisibleMonthChange: func.noop, + onModeChange: func.noop, + dateCellRender: value => value.date(), + locale: nextLocale.Calendar, + showOtherMonth: true, + }; + static displayName = 'Calendar'; + MODES: CalendarMode[]; + today: Moment; + + readonly props: InnerCalendarProps; + + constructor(props: CalendarProps) { + super(props); + const value = formatDateValue(props.value || props.defaultValue); + const visibleMonth = getVisibleMonth(props.defaultVisibleMonth, value); + + this.MODES = props.modes!; + this.today = moment(); + this.state = { + value, + mode: props.mode || this.MODES![0], + MODES: this.MODES, + visibleMonth, + }; + } + + static getDerivedStateFromProps(props: CalendarProps, state: CalendarState) { + const st: Partial = {}; + if ('value' in props) { + const value = formatDateValue(props.value); + if (value && isValueChanged(props.value, state.value)) { + st.visibleMonth = value; + } + st.value = value; + } + + if (props.mode && state.MODES.indexOf(props.mode) > -1) { + st.mode = props.mode; + } + + return st; + } + + onSelectCell = (date: Moment, nextMode: CalendarMode | MouseEvent) => { + const { visibleMonth } = this.state; + const { shape, showOtherMonth } = this.props; + + // 点击其他月份日期不生效 + if (!showOtherMonth && !isSameYearMonth(visibleMonth, date)) { + return; + } + + this.changeVisibleMonth(date, 'cellClick'); + + if (!('value' in this.props)) { + // 非受控模式,直接修改当前 state + this.setState({ + value: date, + }); + } + + // 当用户所在的面板为初始化面板时,则选择动作为触发 onSelect 回调 + if (this.state.mode === this.MODES[0]) { + this.props.onSelect(date); + } + + if (shape === 'panel') { + this.changeMode(nextMode as CalendarMode); + } + }; + + changeMode = (nextMode: CalendarMode) => { + if (nextMode && this.MODES.indexOf(nextMode) > -1 && nextMode !== this.state.mode) { + this.setState({ mode: nextMode }); + this.props.onModeChange(nextMode); + } + }; + + changeVisibleMonth = (date: Moment, reason: VisibleMonthChangeType) => { + if (!isSameYearMonth(date, this.state.visibleMonth)) { + this.setState({ visibleMonth: date }); + this.props.onVisibleMonthChange(date, reason); + } + }; + + /** + * 根据日期偏移量设置当前展示的月份 + * @param offset - 日期偏移的数量 + * @param type - 日期偏移的类型 days, months, years + */ + changeVisibleMonthByOffset(offset: number, type: 'days' | 'months' | 'years') { + const cloneValue = this.state.visibleMonth.clone(); + cloneValue.add(offset, type); + this.changeVisibleMonth(cloneValue, 'buttonClick'); + } + + goPrevDecade = () => { + this.changeVisibleMonthByOffset(-10, 'years'); + }; + + goNextDecade = () => { + this.changeVisibleMonthByOffset(10, 'years'); + }; + + goPrevYear = () => { + this.changeVisibleMonthByOffset(-1, 'years'); + }; + + goNextYear = () => { + this.changeVisibleMonthByOffset(1, 'years'); + }; + + goPrevMonth = () => { + this.changeVisibleMonthByOffset(-1, 'months'); + }; + + goNextMonth = () => { + this.changeVisibleMonthByOffset(1, 'months'); + }; + + render() { + const { + prefix, + rtl, + className, + shape, + showOtherMonth, + format, + locale, + dateCellRender, + monthCellRender, + yearCellRender, + disabledDate, + yearRange, + disableChangeMode, + ...others + } = this.props; + const state = this.state; + + const classNames = classnames( + { + [`${prefix}calendar`]: true, + [`${prefix}calendar-${shape}`]: shape, + }, + className + ); + + if (rtl) { + others.dir = 'rtl'; + } + + const visibleMonth = state.visibleMonth; + + // reset moment locale + if (locale.momentLocale) { + state.value && state.value.locale(locale.momentLocale); + visibleMonth.locale(locale.momentLocale); + } + + const localeData = getLocaleData(locale.format || {}, visibleMonth.localeData()); + + const headerProps = { + prefix, + value: state.value, + mode: state.mode, + disableChangeMode, + yearRange, + locale, + rtl, + visibleMonth, + momentLocale: localeData, + changeMode: this.changeMode, + changeVisibleMonth: this.changeVisibleMonth, + goNextDecade: this.goNextDecade, + goNextYear: this.goNextYear, + goNextMonth: this.goNextMonth, + goPrevDecade: this.goPrevDecade, + goPrevYear: this.goPrevYear, + goPrevMonth: this.goPrevMonth, + }; + + const tableProps = { + prefix, + visibleMonth, + showOtherMonth, + value: state.value, + mode: state.mode, + locale, + dateCellRender, + monthCellRender, + yearCellRender, + disabledDate, + momentLocale: localeData, + today: this.today, + goPrevDecade: this.goPrevDecade, + goNextDecade: this.goNextDecade, + }; + + const tables = { + [CALENDAR_MODE_DATE]: ( + + ), + [CALENDAR_MODE_MONTH]: , + [CALENDAR_MODE_YEAR]: ( + + ), + }; + + const panelHeaders = { + [CALENDAR_MODE_DATE]: ( + + ), + [CALENDAR_MODE_MONTH]: , + [CALENDAR_MODE_YEAR]: , + }; + + return ( +
    + {shape === 'panel' ? ( + panelHeaders[state.mode] + ) : ( + + )} + {tables[state.mode]} +
    + ); + } +} + +export default polyfill(Calendar); diff --git a/components/calendar/head/card-header.jsx b/components/calendar/head/card-header.jsx deleted file mode 100644 index 2680e4280e..0000000000 --- a/components/calendar/head/card-header.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import Select from '../../select'; -import Radio from '../../radio'; -import ConfigProvider from '../../config-provider'; - -class CardHeader extends React.Component { - static propTypes = { - yearRange: PropTypes.arrayOf(PropTypes.number), - yearRangeOffset: PropTypes.number, - locale: PropTypes.object, - }; - - static defaultProps = { - yearRangeOffset: 10, - }; - - selectContainerHandler = target => { - const { device } = this.props; - if (device === 'phone') { - return document.body; - } - return target.parentNode; - }; - - getYearSelect(year) { - const { prefix, yearRangeOffset, yearRange = [], locale } = this.props; - - let [startYear, endYear] = yearRange; - if (!startYear || !endYear) { - startYear = year - yearRangeOffset; - endYear = year + yearRangeOffset; - } - - const options = []; - for (let i = startYear; i <= endYear; i++) { - options.push( - - {i} - - ); - } - - return ( - - ); - } - - getMonthSelect(month) { - const { prefix, momentLocale, locale } = this.props; - const localeMonths = momentLocale.monthsShort(); - const options = []; - for (let i = 0; i < 12; i++) { - options.push( - - {localeMonths[i]} - - ); - } - return ( - - ); - } - - onYearChange = year => { - const { visibleMonth, changeVisibleMonth } = this.props; - changeVisibleMonth(visibleMonth.clone().year(year), 'yearSelect'); - }; - - changeVisibleMonth = month => { - const { visibleMonth, changeVisibleMonth } = this.props; - changeVisibleMonth(visibleMonth.clone().month(month), 'monthSelect'); - }; - - onModePanelChange = mode => { - this.props.changeMode(mode); - }; - - render() { - const { prefix, mode, locale, visibleMonth } = this.props; - - const yearSelect = this.getYearSelect(visibleMonth.year()); - const monthSelect = mode === 'month' ? null : this.getMonthSelect(visibleMonth.month()); - const panelSelect = ( - - {locale.month} - {locale.year} - - ); - - return ( -
    - {yearSelect} - {monthSelect} - {panelSelect} -
    - ); - } -} - -export default ConfigProvider.config(CardHeader); diff --git a/components/calendar/head/card-header.tsx b/components/calendar/head/card-header.tsx new file mode 100644 index 0000000000..0a64b4cdf1 --- /dev/null +++ b/components/calendar/head/card-header.tsx @@ -0,0 +1,133 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Select from '../../select'; +import Radio from '../../radio'; +import ConfigProvider from '../../config-provider'; +import type { CalendarMode, CardHeaderProps } from '../types'; + +class CardHeader extends React.Component { + static propTypes = { + yearRange: PropTypes.arrayOf(PropTypes.number), + yearRangeOffset: PropTypes.number, + locale: PropTypes.object, + }; + + static defaultProps = { + yearRangeOffset: 10, + }; + + selectContainerHandler = (target: HTMLElement) => { + const { device } = this.props; + if (device === 'phone') { + return document.body; + } + return target.parentNode as HTMLElement; + }; + + getYearSelect(year: number) { + const { + prefix, + yearRangeOffset, + yearRange = [], + locale, + showOtherMonth, + mode, + } = this.props; + + let [startYear, endYear] = yearRange; + if (!startYear || !endYear) { + startYear = year - yearRangeOffset!; + endYear = year + yearRangeOffset!; + } + + const options = []; + for (let i = startYear; i <= endYear; i++) { + options.push( + + {i} + + ); + } + + return ( + + ); + } + + getMonthSelect(month: number) { + const { prefix, momentLocale, locale, showOtherMonth, mode } = this.props; + const localeMonths = momentLocale.monthsShort(); + const options = []; + for (let i = 0; i < 12; i++) { + options.push( + + {localeMonths[i]} + + ); + } + return ( + + ); + } + + onYearChange = (year: number) => { + const { visibleMonth, changeVisibleMonth } = this.props; + changeVisibleMonth(visibleMonth.clone().year(year), 'yearSelect'); + }; + + changeVisibleMonth = (month: number) => { + const { visibleMonth, changeVisibleMonth } = this.props; + changeVisibleMonth(visibleMonth.clone().month(month), 'monthSelect'); + }; + + onModePanelChange = (mode: CalendarMode) => { + this.props.changeMode(mode); + }; + + render() { + const { prefix, mode, locale, visibleMonth, showOtherMonth } = this.props; + + const yearSelect = this.getYearSelect(visibleMonth.year()); + const monthSelect = mode === 'month' ? null : this.getMonthSelect(visibleMonth.month()); + const panelSelect = + !showOtherMonth && mode === 'date' ? null : ( + + {locale.month} + {locale.year} + + ); + + return ( +
    + {yearSelect} + {monthSelect} + {panelSelect} +
    + ); + } +} + +export default ConfigProvider.config(CardHeader); diff --git a/components/calendar/head/date-panel-header.jsx b/components/calendar/head/date-panel-header.jsx deleted file mode 100644 index cdaa0c7070..0000000000 --- a/components/calendar/head/date-panel-header.jsx +++ /dev/null @@ -1,167 +0,0 @@ -/* istanbul ignore file */ -import React from 'react'; -import Icon from '../../icon'; -import Dropdown from '../../dropdown'; -import SelectMenu from './menu'; -import { getMonths, getYears } from '../utils'; - -/* eslint-disable */ -class DatePanelHeader extends React.PureComponent { - static defaultProps = { - yearRangeOffset: 10, - }; - - selectContainerHandler = target => { - return target.parentNode; - }; - - onYearChange = year => { - const { visibleMonth, changeVisibleMonth } = this.props; - changeVisibleMonth(visibleMonth.clone().year(year), 'yearSelect'); - }; - - changeVisibleMonth = month => { - const { visibleMonth, changeVisibleMonth } = this.props; - changeVisibleMonth(visibleMonth.clone().month(month), 'monthSelect'); - }; - - render() { - const { - prefix, - visibleMonth, - momentLocale, - locale, - changeMode, - goNextMonth, - goNextYear, - goPrevMonth, - goPrevYear, - disableChangeMode, - yearRangeOffset, - yearRange = [], - } = this.props; - - const localedMonths = momentLocale.months(); - const monthLabel = localedMonths[visibleMonth.month()]; - const yearLabel = visibleMonth.year(); - const btnCls = `${prefix}calendar-btn`; - - let monthButton = ( - - ); - - let yearButton = ( - - ); - - if (disableChangeMode) { - const months = getMonths(momentLocale); - const years = getYears(yearRange, yearRangeOffset, visibleMonth.year()); - - monthButton = ( - - {monthLabel} - - - } - triggerType="click" - > - this.changeVisibleMonth(value)} - /> - - ); - - yearButton = ( - - {yearLabel} - - - } - triggerType="click" - > - - - ); - } - - return ( -
    - - -
    - {monthButton} - {yearButton} -
    - - -
    - ); - } -} - -export default DatePanelHeader; diff --git a/components/calendar/head/date-panel-header.tsx b/components/calendar/head/date-panel-header.tsx new file mode 100644 index 0000000000..ce95b06f2a --- /dev/null +++ b/components/calendar/head/date-panel-header.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import Icon from '../../icon'; +import Dropdown from '../../dropdown'; +import SelectMenu from './menu'; +import { getMonths, getYears } from '../utils'; +import { type DatePanelHeaderProps } from '../types'; + +class DatePanelHeader extends React.PureComponent { + static defaultProps = { + yearRangeOffset: 10, + }; + + selectContainerHandler = (target: HTMLElement) => { + return target.parentNode; + }; + + onYearChange = (year: number) => { + const { visibleMonth, changeVisibleMonth } = this.props; + changeVisibleMonth(visibleMonth.clone().year(year), 'yearSelect'); + }; + + changeVisibleMonth = (month: number) => { + const { visibleMonth, changeVisibleMonth } = this.props; + changeVisibleMonth(visibleMonth.clone().month(month), 'monthSelect'); + }; + + render() { + const { + prefix, + visibleMonth, + momentLocale, + locale, + showOtherMonth, + changeMode, + goNextMonth, + goNextYear, + goPrevMonth, + goPrevYear, + disableChangeMode, + yearRangeOffset, + yearRange = [], + } = this.props; + + const localedMonths = momentLocale.months(); + const monthLabel = localedMonths[visibleMonth.month()]; + const yearLabel = visibleMonth.year(); + const btnCls = `${prefix}calendar-btn`; + + let monthButton = ( + + ); + + let yearButton = ( + + ); + + if (disableChangeMode) { + const months = getMonths(momentLocale); + const years = getYears(yearRange, yearRangeOffset, visibleMonth.year()); + + monthButton = ( + + {monthLabel} + + + } + triggerType="click" + > + this.changeVisibleMonth(value)} + /> + + ); + + yearButton = ( + + {yearLabel} + + + } + triggerType="click" + > + + + ); + } + + return ( +
    + {showOtherMonth && ( + <> + + + + )} +
    + {monthButton} + {yearButton} +
    + {showOtherMonth && ( + <> + + + + )} +
    + ); + } +} + +export default DatePanelHeader; diff --git a/components/calendar/head/menu.jsx b/components/calendar/head/menu.jsx deleted file mode 100644 index f08a1a3bb1..0000000000 --- a/components/calendar/head/menu.jsx +++ /dev/null @@ -1,62 +0,0 @@ -/* istanbul ignore file */ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { findDOMNode } from 'react-dom'; -import Menu from '../../menu'; - -export default class SelectMenu extends Component { - static isNextMenu = true; - static propTypes = { - dataSource: PropTypes.arrayOf(PropTypes.object), - value: PropTypes.number, - prefix: PropTypes.string, - onChange: PropTypes.func, - children: PropTypes.node, - }; - - componentDidMount() { - this.scrollToSelectedItem(); - } - - scrollToSelectedItem() { - const { prefix, dataSource, value } = this.props; - - const selectedIndex = dataSource.findIndex(item => item.value === value); - - if (selectedIndex === -1) { - return; - } - - const itemSelector = `.${prefix}menu-item`; - const menu = findDOMNode(this.menuEl); - const targetItem = menu.querySelectorAll(itemSelector)[selectedIndex]; - if (targetItem) { - menu.scrollTop = - targetItem.offsetTop - - Math.floor((menu.clientHeight / targetItem.clientHeight - 1) / 2) * targetItem.clientHeight; - } - } - - saveRef = ref => { - this.menuEl = ref; - }; - - render() { - const { prefix, dataSource, onChange, value, className, ...others } = this.props; - return ( - onChange(Number(selectKeys[0]))} - role="listbox" - className={`${prefix}calendar-panel-menu ${className}`} - > - {dataSource.map(({ label, value }) => ( - {label} - ))} - - ); - } -} diff --git a/components/calendar/head/menu.tsx b/components/calendar/head/menu.tsx new file mode 100644 index 0000000000..8133ed2ec6 --- /dev/null +++ b/components/calendar/head/menu.tsx @@ -0,0 +1,64 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; +import Menu from '../../menu'; +import type { SelectMenuProps } from '../types'; + +export default class SelectMenu extends Component { + static isNextMenu = true; + static propTypes = { + dataSource: PropTypes.arrayOf(PropTypes.object), + value: PropTypes.number, + prefix: PropTypes.string, + onChange: PropTypes.func, + children: PropTypes.node, + }; + menuEl: InstanceType | null; + + componentDidMount() { + this.scrollToSelectedItem(); + } + + scrollToSelectedItem() { + const { prefix, dataSource, value } = this.props; + + const selectedIndex = dataSource.findIndex(item => item.value === value); + + if (selectedIndex === -1) { + return; + } + + const itemSelector = `.${prefix}menu-item`; + const menu = findDOMNode(this.menuEl) as HTMLElement; + const targetItem = menu!.querySelectorAll(itemSelector)[selectedIndex] as HTMLElement; + if (targetItem) { + menu.scrollTop = + targetItem.offsetTop - + Math.floor((menu.clientHeight / targetItem.clientHeight - 1) / 2) * + targetItem.clientHeight; + } + } + + saveRef = (ref: InstanceType | null) => { + this.menuEl = ref; + }; + + render() { + const { prefix, dataSource, onChange, value, className, ...others } = this.props; + return ( + onChange(Number(selectKeys[0]))} + role="listbox" + className={`${prefix}calendar-panel-menu ${className}`} + > + {dataSource.map(({ label, value }) => ( + {label} + ))} + + ); + } +} diff --git a/components/calendar/head/month-panel-header.jsx b/components/calendar/head/month-panel-header.jsx deleted file mode 100644 index 6d4a3b7375..0000000000 --- a/components/calendar/head/month-panel-header.jsx +++ /dev/null @@ -1,46 +0,0 @@ -import React from 'react'; -import Icon from '../../icon'; - -class MonthPanelHeader extends React.PureComponent { - render() { - const { prefix, visibleMonth, locale, changeMode, goPrevYear, goNextYear } = this.props; - const yearLabel = visibleMonth.year(); - const btnCls = `${prefix}calendar-btn`; - - return ( -
    - -
    - -
    - -
    - ); - } -} - -export default MonthPanelHeader; diff --git a/components/calendar/head/month-panel-header.tsx b/components/calendar/head/month-panel-header.tsx new file mode 100644 index 0000000000..e26f9f6519 --- /dev/null +++ b/components/calendar/head/month-panel-header.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import Icon from '../../icon'; +import { type MonthPanelHeaderProps } from '../types'; + +class MonthPanelHeader extends React.PureComponent { + render() { + const { prefix, visibleMonth, locale, changeMode, goPrevYear, goNextYear } = this.props; + const yearLabel = visibleMonth.year(); + const btnCls = `${prefix}calendar-btn`; + + return ( +
    + +
    + +
    + +
    + ); + } +} + +export default MonthPanelHeader; diff --git a/components/calendar/head/range-panel-header.jsx b/components/calendar/head/range-panel-header.jsx deleted file mode 100644 index 2912460858..0000000000 --- a/components/calendar/head/range-panel-header.jsx +++ /dev/null @@ -1,228 +0,0 @@ -/* istanbul ignore file */ -import React from 'react'; -import Icon from '../../icon'; -import Dropdown from '../../dropdown'; -import SelectMenu from './menu'; -import { getMonths, getYears } from '../utils'; - -/* eslint-disable */ -class RangePanelHeader extends React.PureComponent { - static defaultProps = { - yearRangeOffset: 10, - }; - - selectContainerHandler = target => { - return target.parentNode; - }; - - onYearChange = (visibleMonth, year, tag) => { - const { changeVisibleMonth } = this.props; - const startYear = visibleMonth - .clone() - .year(year) - .add(tag === 'end' ? -1 : 0, 'month'); - changeVisibleMonth(startYear, 'yearSelect'); - }; - - changeVisibleMonth = (visibleMonth, month, tag) => { - const { changeVisibleMonth } = this.props; - const startMonth = tag === 'end' ? month - 1 : month; - changeVisibleMonth(visibleMonth.clone().month(startMonth), 'monthSelect'); - }; - - render() { - const { - prefix, - startVisibleMonth, - endVisibleMonth, - yearRange = [], - yearRangeOffset, - momentLocale, - locale, - changeMode, - goNextMonth, - goNextYear, - goPrevMonth, - goPrevYear, - disableChangeMode, - } = this.props; - - const localedMonths = momentLocale.months(); - const startMonthLabel = localedMonths[startVisibleMonth.month()]; - const endMonthLabel = localedMonths[endVisibleMonth.month()]; - const startYearLabel = startVisibleMonth.year(); - const endYearLabel = endVisibleMonth.year(); - const btnCls = `${prefix}calendar-btn`; - - const months = getMonths(momentLocale); - const startYears = getYears(yearRange, yearRangeOffset, startVisibleMonth.year()); - const endYears = getYears(yearRange, yearRangeOffset, endVisibleMonth.year()); - - return ( -
    - - -
    - {disableChangeMode ? ( - - {startMonthLabel} - - - } - triggerType="click" - > - this.changeVisibleMonth(startVisibleMonth, value, 'start')} - /> - - ) : ( - - )} - {disableChangeMode ? ( - - {startYearLabel} - - - } - triggerType="click" - > - this.onYearChange(startVisibleMonth, v, 'start')} - /> - - ) : ( - - )} -
    -
    - {disableChangeMode ? ( - - {endMonthLabel} - - - } - triggerType="click" - > - this.changeVisibleMonth(endVisibleMonth, value, 'end')} - /> - - ) : ( - - )} - {disableChangeMode ? ( - - {endYearLabel} - - - } - triggerType="click" - > - this.onYearChange(endVisibleMonth, v, 'end')} - /> - - ) : ( - - )} -
    - - -
    - ); - } -} - -export default RangePanelHeader; diff --git a/components/calendar/head/range-panel-header.tsx b/components/calendar/head/range-panel-header.tsx new file mode 100644 index 0000000000..569836b52d --- /dev/null +++ b/components/calendar/head/range-panel-header.tsx @@ -0,0 +1,242 @@ +import React from 'react'; +import { type Moment } from 'moment'; +import Icon from '../../icon'; +import Dropdown from '../../dropdown'; +import SelectMenu from './menu'; +import { getMonths, getYears } from '../utils'; +import { type RangePanelHeaderProps } from '../types'; + +class RangePanelHeader extends React.PureComponent { + static defaultProps = { + yearRangeOffset: 10, + }; + + selectContainerHandler = (target: HTMLElement) => { + return target.parentNode as HTMLElement; + }; + + onYearChange = (visibleMonth: Moment, year: number, tag: 'start' | 'end') => { + const { changeVisibleMonth } = this.props; + const startYear = visibleMonth + .clone() + .year(year) + .add(tag === 'end' ? -1 : 0, 'month'); + changeVisibleMonth(startYear, 'yearSelect'); + }; + + changeVisibleMonth = (visibleMonth: Moment, month: number, tag: 'start' | 'end') => { + const { changeVisibleMonth } = this.props; + const startMonth = tag === 'end' ? month - 1 : month; + changeVisibleMonth(visibleMonth.clone().month(startMonth), 'monthSelect'); + }; + + render() { + const { + prefix, + startVisibleMonth, + endVisibleMonth, + yearRange = [], + yearRangeOffset, + momentLocale, + locale, + changeMode, + goNextMonth, + goNextYear, + goPrevMonth, + goPrevYear, + disableChangeMode, + } = this.props; + + const localedMonths = momentLocale.months(); + const startMonthLabel = localedMonths[startVisibleMonth.month()]; + const endMonthLabel = localedMonths[endVisibleMonth.month()]; + const startYearLabel = startVisibleMonth.year(); + const endYearLabel = endVisibleMonth.year(); + const btnCls = `${prefix}calendar-btn`; + + const months = getMonths(momentLocale); + const startYears = getYears(yearRange, yearRangeOffset!, startVisibleMonth.year()); + const endYears = getYears(yearRange, yearRangeOffset!, endVisibleMonth.year()); + + return ( +
    + + +
    + {disableChangeMode ? ( + + {startMonthLabel} + + + } + triggerType="click" + > + + this.changeVisibleMonth(startVisibleMonth, value, 'start') + } + /> + + ) : ( + + )} + {disableChangeMode ? ( + + {startYearLabel} + + + } + triggerType="click" + > + this.onYearChange(startVisibleMonth, v, 'start')} + /> + + ) : ( + + )} +
    +
    + {disableChangeMode ? ( + + {endMonthLabel} + + + } + triggerType="click" + > + + this.changeVisibleMonth(endVisibleMonth, value, 'end') + } + /> + + ) : ( + + )} + {disableChangeMode ? ( + + {endYearLabel} + + + } + triggerType="click" + > + this.onYearChange(endVisibleMonth, v, 'end')} + /> + + ) : ( + + )} +
    + + +
    + ); + } +} + +export default RangePanelHeader; diff --git a/components/calendar/head/year-panel-header.jsx b/components/calendar/head/year-panel-header.jsx deleted file mode 100644 index 43c1bd8077..0000000000 --- a/components/calendar/head/year-panel-header.jsx +++ /dev/null @@ -1,47 +0,0 @@ -import React from 'react'; -import Icon from '../../icon'; - -class YearPanelHeader extends React.PureComponent { - getDecadeLabel = date => { - const year = date.year(); - const start = parseInt(year / 10, 10) * 10; - const end = start + 9; - return `${start}-${end}`; - }; - - render() { - const { prefix, visibleMonth, locale, goPrevDecade, goNextDecade } = this.props; - const decadeLable = this.getDecadeLabel(visibleMonth); - const btnCls = `${prefix}calendar-btn`; - - return ( -
    - -
    - -
    - -
    - ); - } -} - -export default YearPanelHeader; diff --git a/components/calendar/head/year-panel-header.tsx b/components/calendar/head/year-panel-header.tsx new file mode 100644 index 0000000000..dbff012512 --- /dev/null +++ b/components/calendar/head/year-panel-header.tsx @@ -0,0 +1,54 @@ +import React from 'react'; +import { type Moment } from 'moment'; +import Icon from '../../icon'; +import { type YearPanelHeaderProps } from '../types'; + +class YearPanelHeader extends React.PureComponent { + getDecadeLabel = (date: Moment) => { + const year = date.year(); + // @ts-expect-error parseInt 接收的参数类型是 string + const start = parseInt(year / 10, 10) * 10; + const end = start + 9; + return `${start}-${end}`; + }; + + render() { + const { prefix, visibleMonth, locale, goPrevDecade, goNextDecade } = this.props; + const decadeLable = this.getDecadeLabel(visibleMonth); + const btnCls = `${prefix}calendar-btn`; + + return ( +
    + +
    + +
    + +
    + ); + } +} + +export default YearPanelHeader; diff --git a/components/calendar/index.d.ts b/components/calendar/index.d.ts deleted file mode 100644 index a52ea31b9c..0000000000 --- a/components/calendar/index.d.ts +++ /dev/null @@ -1,68 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onSelect?: any; -} - -export interface CalendarProps extends HTMLAttributesWeak, CommonProps { - /** - * 默认选中的日期(moment 对象) - */ - defaultValue?: any; - - /** - * 选中的日期值 (moment 对象) - */ - value?: any; - - /** - * 是否展示非本月的日期 - */ - showOtherMonth?: boolean; - - /** - * 默认展示的月份 - */ - defaultVisibleMonth?: () => void; - - /** - * 展现形态 - */ - shape?: 'card' | 'fullscreen' | 'panel'; - - /** - * 选择日期单元格时的回调 - */ - onSelect?: (value: {}) => void; - - /** - * 展现的月份变化时的回调 - */ - onVisibleMonthChange?: (value: {}, reason: string) => void; - - /** - * 自定义样式类 - */ - className?: string; - - /** - * 自定义日期渲染函数 - */ - dateCellRender?: (value: {}) => React.ReactNode; - - /** - * 自定义月份渲染函数 - */ - monthCellRender?: (calendarDate: {}) => React.ReactNode; - - /** - * 不可选择的日期 - */ - disabledDate?: (calendarDate: {}, view: string) => boolean; -} - -export default class Calendar extends React.Component {} diff --git a/components/calendar/index.jsx b/components/calendar/index.jsx deleted file mode 100644 index 8a77193b24..0000000000 --- a/components/calendar/index.jsx +++ /dev/null @@ -1,71 +0,0 @@ -import ConfigProvider from '../config-provider'; -import { preFormatDateValue } from './utils'; -import Calendar from './calendar'; -import RangeCalendar from './range-calendar'; - -/* istanbul ignore next */ -const transform = (props, deprecated) => { - const { type, onChange, base, disabledMonth, disabledYear, ...others } = props; - const newProps = others; - - if ('type' in props) { - deprecated('type', 'shape', 'Calendar'); - - newProps.shape = type; - - if ('shape' in props) { - newProps.shape = props.shape; - } - } - - if ('base' in props) { - deprecated('base', 'defaultVisibleMonth', 'Calendar'); - - let newDefaultVisibleMonth = () => { - preFormatDateValue(base, 'YYYY-MM-DD'); - }; - - if ('defaultVisibleMonth' in props) { - newDefaultVisibleMonth = props.defaultVisibleMonth; - } - - newProps.defaultVisibleMonth = newDefaultVisibleMonth; - } - - if ('onChange' in props && typeof onChange === 'function') { - deprecated('onChange', 'onSelect', 'Calendar'); - - const newOnSelect = date => { - onChange({ mode: others.mode, value: date }); - - if ('onSelect' in props) { - props.onSelect(date); - } - }; - - newProps.onSelect = newOnSelect; - } - - if ('disabledMonth' in props && typeof disabledMonth === 'function') { - deprecated('disabledMonth', 'disabledDate', 'Calendar'); - } - - if ('disabledYear' in props && typeof disabledYear === 'function') { - deprecated('disabledYear', 'disabledDate', 'Calendar'); - } - - if ('yearCellRender' in props && typeof yearCellRender === 'function') { - deprecated('yearCellRender', 'monthCellRender/dateCellRender', 'Calendar'); - } - - if ('language' in props) { - deprecated('language', 'moment.locale', 'Calendar'); - } - - return newProps; -}; - -Calendar.RangeCalendar = RangeCalendar; -export default ConfigProvider.config(Calendar, { - transform, -}); diff --git a/components/calendar/index.tsx b/components/calendar/index.tsx new file mode 100644 index 0000000000..fe828701c1 --- /dev/null +++ b/components/calendar/index.tsx @@ -0,0 +1,77 @@ +import { type Moment } from 'moment'; +import ConfigProvider from '../config-provider'; +import { preFormatDateValue } from './utils'; +import Calendar from './calendar'; +import RangeCalendar from './range-calendar'; +import { assignSubComponent } from '../util/component'; +import type { CalendarProps } from './types'; +import type { log } from '../util'; + +export type { CalendarProps, RangeCalendarProps, CalendarMode } from './types'; + +const transform = (props: CalendarProps, deprecated: typeof log.deprecated) => { + const { type, onChange, base, disabledMonth, disabledYear, yearCellRender, ...others } = props; + const newProps = others; + + if ('type' in props) { + deprecated('type', 'shape', 'Calendar'); + + newProps.shape = type; + + if ('shape' in props) { + newProps.shape = props.shape; + } + } + + if ('base' in props) { + deprecated('base', 'defaultVisibleMonth', 'Calendar'); + + let newDefaultVisibleMonth = () => { + return preFormatDateValue(base, 'YYYY-MM-DD'); + }; + + if ('defaultVisibleMonth' in props) { + newDefaultVisibleMonth = props.defaultVisibleMonth!; + } + + newProps.defaultVisibleMonth = newDefaultVisibleMonth; + } + + if ('onChange' in props && typeof onChange === 'function') { + deprecated('onChange', 'onSelect', 'Calendar'); + + const newOnSelect = (date: Moment) => { + onChange({ mode: others.mode!, value: date }); + + if ('onSelect' in props) { + props.onSelect!(date); + } + }; + + newProps.onSelect = newOnSelect; + } + + if ('disabledMonth' in props && typeof disabledMonth === 'function') { + deprecated('disabledMonth', 'disabledDate', 'Calendar'); + } + + if ('disabledYear' in props && typeof disabledYear === 'function') { + deprecated('disabledYear', 'disabledDate', 'Calendar'); + } + + if ('yearCellRender' in props && typeof yearCellRender === 'function') { + deprecated('yearCellRender', 'monthCellRender/dateCellRender', 'Calendar'); + } + + if ('language' in props) { + deprecated('language', 'moment.locale', 'Calendar'); + } + + return newProps; +}; + +const CalendarWithSub = assignSubComponent(Calendar, { RangeCalendar }); + +export default ConfigProvider.config(CalendarWithSub, { + transform, +}); diff --git a/components/calendar/mobile/index.jsx b/components/calendar/mobile/index.tsx similarity index 100% rename from components/calendar/mobile/index.jsx rename to components/calendar/mobile/index.tsx diff --git a/components/calendar/range-calendar.jsx b/components/calendar/range-calendar.jsx deleted file mode 100644 index 244b8b77c1..0000000000 --- a/components/calendar/range-calendar.jsx +++ /dev/null @@ -1,384 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import classnames from 'classnames'; -import moment from 'moment'; -import ConfigProvider from '../config-provider'; -import nextLocale from '../locale/zh-cn'; -import { obj, func } from '../util'; -import RangePanelHeader from './head/range-panel-header'; -import MonthPanelHeader from './head/month-panel-header'; -import YearPanelHeader from './head/year-panel-header'; -import DateTable from './table/date-table'; -import MonthTable from './table/month-table'; -import YearTable from './table/year-table'; -import { - checkMomentObj, - formatDateValue, - getVisibleMonth, - isSameYearMonth, - CALENDAR_MODES, - CALENDAR_MODE_DATE, - CALENDAR_MODE_MONTH, - CALENDAR_MODE_YEAR, - getLocaleData, -} from './utils'; - -class RangeCalendar extends React.Component { - static propTypes = { - ...ConfigProvider.propTypes, - /** - * 样式前缀 - */ - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 默认的开始日期 - */ - defaultStartValue: checkMomentObj, - /** - * 默认的结束日期 - */ - defaultEndValue: checkMomentObj, - /** - * 开始日期(moment 对象) - */ - startValue: checkMomentObj, - /** - * 结束日期(moment 对象) - */ - endValue: checkMomentObj, - // 面板模式 - mode: PropTypes.oneOf(CALENDAR_MODES), - // 禁用更改面板模式,采用 dropdown 的方式切换显示日期 (暂不正式对外透出) - disableChangeMode: PropTypes.bool, - // 日期值的格式(用于日期title显示的格式) - format: PropTypes.string, - yearRange: PropTypes.arrayOf(PropTypes.number), - /** - * 是否显示非本月的日期 - */ - showOtherMonth: PropTypes.bool, - /** - * 模板展示的月份(起始月份) - */ - defaultVisibleMonth: PropTypes.func, - /** - * 展现的月份变化时的回调 - * @param {Object} value 显示的月份 (moment 对象) - * @param {String} reason 触发月份改变原因 - */ - onVisibleMonthChange: PropTypes.func, - /** - * 不可选择的日期 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @returns {Boolean} - */ - disabledDate: PropTypes.func, - /** - * 选择日期单元格时的回调 - * @param {Object} value 对应的日期值 (moment 对象) - */ - onSelect: PropTypes.func, - /** - * 自定义日期单元格渲染 - */ - dateCellRender: PropTypes.func, - /** - * 自定义月份渲染函数 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @returns {ReactNode} - */ - monthCellRender: PropTypes.func, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - locale: PropTypes.object, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - mode: CALENDAR_MODE_DATE, - disableChangeMode: false, - format: 'YYYY-MM-DD', - dateCellRender: value => value.date(), - onSelect: func.noop, - onVisibleMonthChange: func.noop, - locale: nextLocale.Calendar, - showOtherMonth: false, - }; - - constructor(props, context) { - super(props, context); - - const startValue = formatDateValue(props.startValue || props.defaultStartValue); - const endValue = formatDateValue(props.endValue || props.defaultEndValue); - const visibleMonth = getVisibleMonth(props.defaultVisibleMonth, startValue); - - this.state = { - startValue, - endValue, - mode: props.mode, - prevMode: props.mode, - startVisibleMonth: visibleMonth, - activePanel: undefined, - lastMode: undefined, - lastPanelType: 'start', // enum, 包括 start end - }; - this.today = moment(); - } - - static getDerivedStateFromProps(props, state) { - const st = {}; - if ('startValue' in props) { - const startValue = formatDateValue(props.startValue); - st.startValue = startValue; - if (startValue && !startValue.isSame(state.startValue, 'day')) { - st.startVisibleMonth = startValue; - } - } - - if ('endValue' in props) { - st.endValue = formatDateValue(props.endValue); - } - - if ('mode' in props && state.prevMode !== props.mode) { - st.prevMode = props.mode; - st.mode = props.mode; - } - - return st; - } - - onSelectCell = (date, nextMode) => { - if (this.state.mode === CALENDAR_MODE_DATE) { - this.props.onSelect(date); - } else { - this.changeVisibleMonth(date, 'cellClick'); - } - - this.changeMode(nextMode); - }; - - changeMode = (mode, activePanel) => { - const { lastMode, lastPanelType } = this.state; - - const state = { - lastMode: mode, - // rangePicker的panel下,选 year -> month ,从当前函数的activePanel传来的数据已经拿不到 start end panel的状态了,需要根据 lastMode 来判断 - lastPanelType: lastMode === 'year' ? lastPanelType : activePanel, - }; - if (typeof mode === 'string' && mode !== this.state.mode) { - state.mode = mode; - } - if (activePanel && activePanel !== this.state.activePanel) { - state.activePanel = activePanel; - } - - this.setState(state); - }; - - changeVisibleMonth = (date, reason) => { - const { lastPanelType } = this.state; - if (!isSameYearMonth(date, this.state.startVisibleMonth)) { - const startVisibleMonth = lastPanelType === 'end' ? date.clone().add(-1, 'month') : date; - this.setState({ startVisibleMonth }); - this.props.onVisibleMonthChange(startVisibleMonth, reason); - } - }; - - /** - * 根据日期偏移量设置当前展示的月份 - * @param {Number} offset 日期偏移量 - * @param {String} type 日期偏移类型 days, months, years - */ - changeVisibleMonthByOffset = (offset, type) => { - const offsetDate = this.state.startVisibleMonth.clone().add(offset, type); - this.changeVisibleMonth(offsetDate, 'buttonClick'); - }; - - goPrevDecade = () => { - this.changeVisibleMonthByOffset(-10, 'years'); - }; - - goNextDecade = () => { - this.changeVisibleMonthByOffset(10, 'years'); - }; - - goPrevYear = () => { - this.changeVisibleMonthByOffset(-1, 'years'); - }; - - goNextYear = () => { - this.changeVisibleMonthByOffset(1, 'years'); - }; - - goPrevMonth = () => { - this.changeVisibleMonthByOffset(-1, 'months'); - }; - - goNextMonth = () => { - this.changeVisibleMonthByOffset(1, 'months'); - }; - - render() { - const { - prefix, - rtl, - dateCellRender, - monthCellRender, - yearCellRender, - className, - format, - locale, - showOtherMonth, - disabledDate, - disableChangeMode, - yearRange, - ...others - } = this.props; - const { startValue, endValue, mode, startVisibleMonth, activePanel } = this.state; - - // reset moment locale - if (locale.momentLocale) { - startValue && startValue.locale(locale.momentLocale); - endValue && endValue.locale(locale.momentLocale); - startVisibleMonth.locale(locale.momentLocale); - } - - if (rtl) { - others.dir = 'rtl'; - } - const localeData = getLocaleData(locale.format || {}, startVisibleMonth.localeData()); - - const endVisibleMonth = startVisibleMonth.clone().add(1, 'months'); - - const headerProps = { - prefix, - rtl, - mode, - locale, - momentLocale: localeData, - startVisibleMonth, - endVisibleMonth, - changeVisibleMonth: this.changeVisibleMonth, - changeMode: this.changeMode, - yearRange, - disableChangeMode, - }; - - const tableProps = { - prefix, - value: startValue, - startValue, - endValue, - mode, - locale, - momentLocale: localeData, - showOtherMonth, - today: this.today, - disabledDate, - dateCellRender, - monthCellRender, - yearCellRender, - changeMode: this.changeMode, - changeVisibleMonth: this.changeVisibleMonth, - }; - - const visibleMonths = { - start: startVisibleMonth, - end: endVisibleMonth, - }; - - const visibleMonth = visibleMonths[activePanel]; - - let header; - let table; - - switch (mode) { - case CALENDAR_MODE_DATE: { - table = [ -
    - -
    , -
    - -
    , - ]; - header = ( - - ); - break; - } - case CALENDAR_MODE_MONTH: { - table = ; - header = ( - - ); - break; - } - case CALENDAR_MODE_YEAR: { - table = ( - - ); - header = ( - - ); - break; - } - } - - const classNames = classnames( - { - [`${prefix}calendar`]: true, - [`${prefix}calendar-range`]: true, - }, - className - ); - - return ( -
    - {header} -
    {table}
    -
    - ); - } -} - -export default ConfigProvider.config(polyfill(RangeCalendar), { - componentName: 'Calendar', -}); diff --git a/components/calendar/range-calendar.tsx b/components/calendar/range-calendar.tsx new file mode 100644 index 0000000000..ac66d473f4 --- /dev/null +++ b/components/calendar/range-calendar.tsx @@ -0,0 +1,358 @@ +import React, { type MouseEvent } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import ConfigProvider from '../config-provider'; +import nextLocale from '../locale/zh-cn'; +import { obj, func, type ClassPropsWithDefault } from '../util'; +import RangePanelHeader from './head/range-panel-header'; +import MonthPanelHeader from './head/month-panel-header'; +import YearPanelHeader from './head/year-panel-header'; +import DateTable from './table/date-table'; +import MonthTable from './table/month-table'; +import YearTable from './table/year-table'; +import { + checkMomentObj, + formatDateValue, + getVisibleMonth, + isSameYearMonth, + CALENDAR_MODES, + CALENDAR_MODE_DATE, + CALENDAR_MODE_MONTH, + CALENDAR_MODE_YEAR, + getLocaleData, +} from './utils'; +import type { + CalendarMode, + RangeCalendarProps, + RangeCalendarState, + VisibleMonthChangeType, +} from './types'; + +type InnerRangeCalendarProps = ClassPropsWithDefault< + RangeCalendarProps, + typeof RangeCalendar.defaultProps +>; + +class RangeCalendar extends React.Component { + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + defaultStartValue: checkMomentObj, + defaultEndValue: checkMomentObj, + startValue: checkMomentObj, + endValue: checkMomentObj, + mode: PropTypes.oneOf(CALENDAR_MODES), + disableChangeMode: PropTypes.bool, + format: PropTypes.string, + yearRange: PropTypes.arrayOf(PropTypes.number), + showOtherMonth: PropTypes.bool, + defaultVisibleMonth: PropTypes.func, + onVisibleMonthChange: PropTypes.func, + disabledDate: PropTypes.func, + onSelect: PropTypes.func, + dateCellRender: PropTypes.func, + monthCellRender: PropTypes.func, + yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender + locale: PropTypes.object, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + mode: CALENDAR_MODE_DATE, + disableChangeMode: false, + format: 'YYYY-MM-DD', + dateCellRender: (value: Moment) => value.date(), + onSelect: func.noop, + onVisibleMonthChange: func.noop, + locale: nextLocale.Calendar, + showOtherMonth: false, + }; + + readonly props: InnerRangeCalendarProps; + today: Moment; + + constructor(props: RangeCalendarProps) { + super(props); + + const startValue = formatDateValue(props.startValue || props.defaultStartValue); + const endValue = formatDateValue(props.endValue || props.defaultEndValue); + const visibleMonth = getVisibleMonth(props.defaultVisibleMonth, startValue); + + this.state = { + startValue, + endValue, + mode: props.mode, + prevMode: props.mode, + startVisibleMonth: visibleMonth, + activePanel: undefined, + lastMode: undefined, + lastPanelType: 'start', // enum, 包括 start end + }; + this.today = moment(); + } + + static getDerivedStateFromProps(props: InnerRangeCalendarProps, state: RangeCalendarState) { + const st: Partial = {}; + if ('startValue' in props) { + const startValue = formatDateValue(props.startValue); + st.startValue = startValue; + if (startValue && !startValue.isSame(state.startValue, 'day')) { + st.startVisibleMonth = startValue; + } + } + + if ('endValue' in props) { + st.endValue = formatDateValue(props.endValue); + } + + if ('mode' in props && state.prevMode !== props.mode) { + st.prevMode = props.mode; + st.mode = props.mode; + } + + return st; + } + + onSelectCell = (date: Moment, nextMode: CalendarMode | MouseEvent) => { + if (this.state.mode === CALENDAR_MODE_DATE) { + this.props.onSelect(date); + } else { + this.changeVisibleMonth(date, 'cellClick'); + } + + this.changeMode(nextMode as CalendarMode); + }; + + changeMode = (mode: CalendarMode, activePanel?: 'start' | 'end') => { + const { lastMode, lastPanelType } = this.state; + + const state = { + lastMode: mode, + // rangePicker 的 panel 下,选 year -> month,从当前函数的 activePanel 传来的数据已经拿不到 start end panel 的状态了,需要根据 lastMode 来判断 + lastPanelType: lastMode === 'year' ? lastPanelType : activePanel, + } as RangeCalendarState; + if (typeof mode === 'string' && mode !== this.state.mode) { + state.mode = mode; + } + if (activePanel && activePanel !== this.state.activePanel) { + state.activePanel = activePanel; + } + + this.setState(state); + }; + + changeVisibleMonth = (date: Moment, reason: VisibleMonthChangeType) => { + const { lastPanelType } = this.state; + if (!isSameYearMonth(date, this.state.startVisibleMonth)) { + const startVisibleMonth = + lastPanelType === 'end' ? date.clone().add(-1, 'month') : date; + this.setState({ startVisibleMonth }); + this.props.onVisibleMonthChange(startVisibleMonth, reason); + } + }; + + /** + * 根据日期偏移量设置当前展示的月份 + * @param offset - 日期偏移量 + * @param type - 日期偏移类型 days, months, years + */ + changeVisibleMonthByOffset = (offset: number, type: 'days' | 'months' | 'years') => { + const offsetDate = this.state.startVisibleMonth.clone().add(offset, type); + this.changeVisibleMonth(offsetDate, 'buttonClick'); + }; + + goPrevDecade = () => { + this.changeVisibleMonthByOffset(-10, 'years'); + }; + + goNextDecade = () => { + this.changeVisibleMonthByOffset(10, 'years'); + }; + + goPrevYear = () => { + this.changeVisibleMonthByOffset(-1, 'years'); + }; + + goNextYear = () => { + this.changeVisibleMonthByOffset(1, 'years'); + }; + + goPrevMonth = () => { + this.changeVisibleMonthByOffset(-1, 'months'); + }; + + goNextMonth = () => { + this.changeVisibleMonthByOffset(1, 'months'); + }; + + render() { + const { + prefix, + rtl, + dateCellRender, + monthCellRender, + yearCellRender, + className, + format, + locale, + showOtherMonth, + disabledDate, + disableChangeMode, + yearRange, + ...others + } = this.props; + const { startValue, endValue, mode, startVisibleMonth, activePanel } = this.state; + + // reset moment locale + if (locale.momentLocale) { + startValue && startValue.locale(locale.momentLocale); + endValue && endValue.locale(locale.momentLocale); + startVisibleMonth.locale(locale.momentLocale); + } + + if (rtl) { + others.dir = 'rtl'; + } + const localeData = getLocaleData(locale.format || {}, startVisibleMonth.localeData()); + + const endVisibleMonth = startVisibleMonth.clone().add(1, 'months'); + + const headerProps = { + prefix, + rtl, + mode, + locale, + momentLocale: localeData, + startVisibleMonth, + endVisibleMonth, + changeVisibleMonth: this.changeVisibleMonth, + changeMode: this.changeMode, + yearRange, + disableChangeMode, + }; + + const tableProps = { + prefix, + value: startValue, + startValue, + endValue, + mode, + locale, + momentLocale: localeData, + showOtherMonth, + today: this.today, + disabledDate, + dateCellRender, + monthCellRender, + yearCellRender, + changeMode: this.changeMode, + changeVisibleMonth: this.changeVisibleMonth, + }; + + const visibleMonths = { + start: startVisibleMonth, + end: endVisibleMonth, + }; + + const visibleMonth = visibleMonths[activePanel!]; + + let header; + let table; + + switch (mode) { + case CALENDAR_MODE_DATE: { + table = [ +
    + +
    , +
    + +
    , + ]; + header = ( + + ); + break; + } + case CALENDAR_MODE_MONTH: { + table = ( + + ); + header = ( + + ); + break; + } + case CALENDAR_MODE_YEAR: { + table = ( + + ); + header = ( + + ); + break; + } + } + + const classNames = classnames( + { + [`${prefix}calendar`]: true, + [`${prefix}calendar-range`]: true, + }, + className + ); + + return ( +
    + {header} +
    {table}
    +
    + ); + } +} + +export default ConfigProvider.config(polyfill(RangeCalendar), { + componentName: 'Calendar', +}); diff --git a/components/calendar/style.js b/components/calendar/style.ts similarity index 100% rename from components/calendar/style.js rename to components/calendar/style.ts diff --git a/components/calendar/table/date-table-head.jsx b/components/calendar/table/date-table-head.jsx deleted file mode 100644 index b3d1691b50..0000000000 --- a/components/calendar/table/date-table-head.jsx +++ /dev/null @@ -1,28 +0,0 @@ -import React, { PureComponent } from 'react'; -import { DAYS_OF_WEEK } from '../utils'; - -class DateTableHead extends PureComponent { - render() { - const { prefix, momentLocale } = this.props; - const firstDayOfWeek = momentLocale.firstDayOfWeek(); - const weekdaysShort = momentLocale.weekdaysShort(); - - const elements = []; - for (let i = 0; i < DAYS_OF_WEEK; i++) { - const index = (firstDayOfWeek + i) % DAYS_OF_WEEK; - elements.push( - - {weekdaysShort[index]} - - ); - } - - return ( - - {elements} - - ); - } -} - -export default DateTableHead; diff --git a/components/calendar/table/date-table-head.tsx b/components/calendar/table/date-table-head.tsx new file mode 100644 index 0000000000..01cd15a13c --- /dev/null +++ b/components/calendar/table/date-table-head.tsx @@ -0,0 +1,29 @@ +import React, { PureComponent } from 'react'; +import { DAYS_OF_WEEK } from '../utils'; +import { type DateTableHeadProps } from '../types'; + +class DateTableHead extends PureComponent { + render() { + const { prefix, momentLocale } = this.props; + const firstDayOfWeek = momentLocale.firstDayOfWeek(); + const weekdaysShort = momentLocale.weekdaysShort(); + + const elements = []; + for (let i = 0; i < DAYS_OF_WEEK; i++) { + const index = (firstDayOfWeek + i) % DAYS_OF_WEEK; + elements.push( + + {weekdaysShort[index]} + + ); + } + + return ( + + {elements} + + ); + } +} + +export default DateTableHead; diff --git a/components/calendar/table/date-table.jsx b/components/calendar/table/date-table.jsx deleted file mode 100644 index f33d4f56ed..0000000000 --- a/components/calendar/table/date-table.jsx +++ /dev/null @@ -1,156 +0,0 @@ -import React, { PureComponent } from 'react'; -import classNames from 'classnames'; -import DateTableHead from './date-table-head'; -import { isDisabledDate, DAYS_OF_WEEK, CALENDAR_TABLE_COL_COUNT, CALENDAR_TABLE_ROW_COUNT } from '../utils'; - -function isSameDay(a, b) { - return a && b && a.isSame(b, 'day'); -} - -function isRangeDate(date, startDate, endDate) { - return ( - date.format('L') !== startDate.format('L') && - date.format('L') !== endDate.format('L') && - date.valueOf() > startDate.valueOf() && - date.valueOf() < endDate.valueOf() - ); -} - -function isLastMonthDate(date, target) { - if (date.year() < target.year()) { - return 1; - } - return date.year() === target.year() && date.month() < target.month(); -} - -function isNextMonthDate(date, target) { - if (date.year() > target.year()) { - return 1; - } - return date.year() === target.year() && date.month() > target.month(); -} - -class DateTable extends PureComponent { - render() { - const { - prefix, - visibleMonth, - showOtherMonth, - endValue, - format, - today, - momentLocale, - dateCellRender, - disabledDate, - onSelectDate, - } = this.props; - const startValue = this.props.startValue || this.props.value; - - const firstDayOfMonth = visibleMonth.clone().startOf('month'); // 该月的 1 号 - const firstDayOfMonthInWeek = firstDayOfMonth.day(); // 星期几 - - const firstDayOfWeek = momentLocale.firstDayOfWeek(); - - const datesOfLastMonthCount = (firstDayOfMonthInWeek + DAYS_OF_WEEK - firstDayOfWeek) % DAYS_OF_WEEK; - - const lastMonthDate = firstDayOfMonth.clone(); - lastMonthDate.add(0 - datesOfLastMonthCount, 'days'); - - let counter = 0; - let currentDate; - const dateList = []; - for (let i = 0; i < CALENDAR_TABLE_ROW_COUNT; i++) { - for (let j = 0; j < CALENDAR_TABLE_COL_COUNT; j++) { - currentDate = lastMonthDate; - if (counter) { - currentDate = currentDate.clone(); - currentDate.add(counter, 'days'); - } - dateList.push(currentDate); - counter++; - } - } - counter = 0; // reset counter - const monthElements = []; - for (let i = 0; i < CALENDAR_TABLE_ROW_COUNT; i++) { - const weekElements = []; - let firstDayOfWeekInCurrentMonth = true; - let lastDayOfWeekInCurrentMonth = true; - for (let j = 0; j < CALENDAR_TABLE_COL_COUNT; j++) { - currentDate = dateList[counter]; - if (j === 0) { - // currentDate 的month 是否等于当前月 firstDayOfMonth - firstDayOfWeekInCurrentMonth = currentDate.format('M') === firstDayOfMonth.format('M'); - } - if (j === CALENDAR_TABLE_COL_COUNT - 1) { - // currentDate 的month 是否等于当前月 firstDayOfMonth - lastDayOfWeekInCurrentMonth = currentDate.format('M') === firstDayOfMonth.format('M'); - } - const isLastMonth = isLastMonthDate(currentDate, visibleMonth); - const isNextMonth = isNextMonthDate(currentDate, visibleMonth); - const isCurrentMonth = !isLastMonth && !isNextMonth; - - const isDisabled = isDisabledDate(currentDate, disabledDate, 'date'); - const isToday = !isDisabled && isSameDay(currentDate, today) && isCurrentMonth; - const isSelected = - !isDisabled && - (isSameDay(currentDate, startValue) || isSameDay(currentDate, endValue)) && - isCurrentMonth; - const isInRange = - !isDisabled && - startValue && - endValue && - isRangeDate(currentDate, startValue, endValue) && - isCurrentMonth; - - const cellContent = !showOtherMonth && !isCurrentMonth ? null : dateCellRender(currentDate); - - const elementCls = classNames({ - [`${prefix}calendar-cell`]: true, - [`${prefix}calendar-cell-prev-month`]: isLastMonth, - [`${prefix}calendar-cell-next-month`]: isNextMonth, - [`${prefix}calendar-cell-current`]: isToday, - [`${prefix}inrange`]: isInRange, - [`${prefix}selected`]: isSelected, - [`${prefix}disabled`]: cellContent && isDisabled, - }); - - weekElements.push( - -
    {cellContent}
    - - ); - counter++; - } - - if (!showOtherMonth && !lastDayOfWeekInCurrentMonth && !firstDayOfWeekInCurrentMonth) { - break; - } - - monthElements.push( - - {weekElements} - - ); - } - - return ( - - - - {monthElements} - -
    - ); - } -} - -export default DateTable; diff --git a/components/calendar/table/date-table.tsx b/components/calendar/table/date-table.tsx new file mode 100644 index 0000000000..9facbcc315 --- /dev/null +++ b/components/calendar/table/date-table.tsx @@ -0,0 +1,165 @@ +import React, { PureComponent } from 'react'; +import classNames from 'classnames'; +import { type Moment } from 'moment'; +import DateTableHead from './date-table-head'; +import { + isDisabledDate, + DAYS_OF_WEEK, + CALENDAR_TABLE_COL_COUNT, + CALENDAR_TABLE_ROW_COUNT, +} from '../utils'; +import type { DateTableProps } from '../types'; + +function isSameDay(a: Moment | null | undefined, b: Moment | null | undefined) { + return a && b && a.isSame(b, 'day'); +} + +function isRangeDate(date: Moment, startDate: Moment, endDate: Moment) { + return ( + date.format('L') !== startDate.format('L') && + date.format('L') !== endDate.format('L') && + date.valueOf() > startDate.valueOf() && + date.valueOf() < endDate.valueOf() + ); +} + +function isLastMonthDate(date: Moment, target: Moment) { + if (date.year() < target.year()) { + return 1; + } + return date.year() === target.year() && date.month() < target.month(); +} + +function isNextMonthDate(date: Moment, target: Moment) { + if (date.year() > target.year()) { + return 1; + } + return date.year() === target.year() && date.month() > target.month(); +} + +class DateTable extends PureComponent { + render() { + const { + prefix, + visibleMonth, + showOtherMonth, + endValue, + format, + today, + momentLocale, + dateCellRender, + disabledDate, + onSelectDate, + } = this.props; + const startValue = this.props.startValue || this.props.value; + + const firstDayOfMonth = visibleMonth.clone().startOf('month'); // 该月的 1 号 + const firstDayOfMonthInWeek = firstDayOfMonth.day(); // 星期几 + + const firstDayOfWeek = momentLocale.firstDayOfWeek(); + + const datesOfLastMonthCount = + (firstDayOfMonthInWeek + DAYS_OF_WEEK - firstDayOfWeek) % DAYS_OF_WEEK; + + const lastMonthDate = firstDayOfMonth.clone(); + lastMonthDate.add(0 - datesOfLastMonthCount, 'days'); + + let counter = 0; + let currentDate; + const dateList = []; + for (let i = 0; i < CALENDAR_TABLE_ROW_COUNT; i++) { + for (let j = 0; j < CALENDAR_TABLE_COL_COUNT; j++) { + currentDate = lastMonthDate; + if (counter) { + currentDate = currentDate.clone(); + currentDate.add(counter, 'days'); + } + dateList.push(currentDate); + counter++; + } + } + counter = 0; // reset counter + const monthElements = []; + for (let i = 0; i < CALENDAR_TABLE_ROW_COUNT; i++) { + const weekElements = []; + let firstDayOfWeekInCurrentMonth = true; + let lastDayOfWeekInCurrentMonth = true; + for (let j = 0; j < CALENDAR_TABLE_COL_COUNT; j++) { + currentDate = dateList[counter]; + if (j === 0) { + // currentDate 的month 是否等于当前月 firstDayOfMonth + firstDayOfWeekInCurrentMonth = + currentDate.format('M') === firstDayOfMonth.format('M'); + } + if (j === CALENDAR_TABLE_COL_COUNT - 1) { + // currentDate 的month 是否等于当前月 firstDayOfMonth + lastDayOfWeekInCurrentMonth = + currentDate.format('M') === firstDayOfMonth.format('M'); + } + const isLastMonth = isLastMonthDate(currentDate, visibleMonth); + const isNextMonth = isNextMonthDate(currentDate, visibleMonth); + const isCurrentMonth = !isLastMonth && !isNextMonth; + + const isDisabled = isDisabledDate(currentDate, disabledDate, 'date'); + const isToday = !isDisabled && isSameDay(currentDate, today) && isCurrentMonth; + const isSelected = + !isDisabled && + (isSameDay(currentDate, startValue) || isSameDay(currentDate, endValue)) && + isCurrentMonth; + const isInRange = + !isDisabled && + startValue && + endValue && + isRangeDate(currentDate, startValue, endValue) && + isCurrentMonth; + + const cellContent = + !showOtherMonth && !isCurrentMonth ? null : dateCellRender(currentDate); + + const elementCls = classNames({ + [`${prefix}calendar-cell`]: true, + [`${prefix}calendar-cell-prev-month`]: isLastMonth, + [`${prefix}calendar-cell-next-month`]: isNextMonth, + [`${prefix}calendar-cell-current`]: isToday, + [`${prefix}inrange`]: isInRange, + [`${prefix}selected`]: isSelected, + [`${prefix}disabled`]: cellContent && isDisabled, + }); + + weekElements.push( + +
    {cellContent}
    + + ); + counter++; + } + + if (!showOtherMonth && !lastDayOfWeekInCurrentMonth && !firstDayOfWeekInCurrentMonth) { + break; + } + + monthElements.push( + + {weekElements} + + ); + } + + return ( + + + {monthElements} +
    + ); + } +} + +export default DateTable; diff --git a/components/calendar/table/month-table.jsx b/components/calendar/table/month-table.jsx deleted file mode 100644 index d2f58bef96..0000000000 --- a/components/calendar/table/month-table.jsx +++ /dev/null @@ -1,68 +0,0 @@ -import React, { PureComponent } from 'react'; -import classnames from 'classnames'; -import { isDisabledDate, MONTH_TABLE_ROW_COUNT, MONTH_TABLE_COL_COUNT } from '../utils'; - -function isSameMonth(currentDate, selectedDate) { - return selectedDate && currentDate.year() === selectedDate.year() && currentDate.month() === selectedDate.month(); -} - -class MonthTable extends PureComponent { - onMonthCellClick(date) { - this.props.onSelectMonth(date, 'date'); - } - - render() { - const { prefix, value, visibleMonth, disabledDate, today, momentLocale, monthCellRender } = this.props; - - const monthLocale = momentLocale.monthsShort(); - - let counter = 0; - const monthList = []; - for (let i = 0; i < MONTH_TABLE_ROW_COUNT; i++) { - const rowList = []; - for (let j = 0; j < MONTH_TABLE_COL_COUNT; j++) { - const monthDate = visibleMonth.clone().month(counter); - const isDisabled = isDisabledDate(monthDate, disabledDate, 'month'); - const isSelected = isSameMonth(monthDate, value); - const isThisMonth = isSameMonth(monthDate, today); - const elementCls = classnames({ - [`${prefix}calendar-cell`]: true, - [`${prefix}calendar-cell-current`]: isThisMonth, - [`${prefix}selected`]: isSelected, - [`${prefix}disabled`]: isDisabled, - }); - const localedMonth = monthLocale[counter]; - const monthCellContent = monthCellRender ? monthCellRender(monthDate) : localedMonth; - rowList.push( - -
    {monthCellContent}
    - - ); - counter++; - } - monthList.push( - - {rowList} - - ); - } - - return ( - - - {monthList} - -
    - ); - } -} - -export default MonthTable; diff --git a/components/calendar/table/month-table.tsx b/components/calendar/table/month-table.tsx new file mode 100644 index 0000000000..0cdd3c37dd --- /dev/null +++ b/components/calendar/table/month-table.tsx @@ -0,0 +1,79 @@ +import React, { PureComponent } from 'react'; +import classnames from 'classnames'; +import { type Moment } from 'moment'; +import { isDisabledDate, MONTH_TABLE_ROW_COUNT, MONTH_TABLE_COL_COUNT } from '../utils'; +import { type MonthTableProps } from '../types'; + +function isSameMonth(currentDate: Moment, selectedDate: Moment | null | undefined) { + return ( + selectedDate && + currentDate.year() === selectedDate.year() && + currentDate.month() === selectedDate.month() + ); +} + +class MonthTable extends PureComponent { + onMonthCellClick(date: Moment) { + this.props.onSelectMonth(date, 'date'); + } + + render() { + const { prefix, value, visibleMonth, disabledDate, today, momentLocale, monthCellRender } = + this.props; + + const monthLocale = momentLocale.monthsShort(); + + let counter = 0; + const monthList = []; + for (let i = 0; i < MONTH_TABLE_ROW_COUNT; i++) { + const rowList = []; + for (let j = 0; j < MONTH_TABLE_COL_COUNT; j++) { + const monthDate = visibleMonth.clone().month(counter); + const isDisabled = isDisabledDate(monthDate, disabledDate, 'month'); + const isSelected = isSameMonth(monthDate, value); + const isThisMonth = isSameMonth(monthDate, today); + const elementCls = classnames({ + [`${prefix}calendar-cell`]: true, + [`${prefix}calendar-cell-current`]: isThisMonth, + [`${prefix}selected`]: isSelected, + [`${prefix}disabled`]: isDisabled, + }); + const localedMonth = monthLocale[counter]; + const monthCellContent = monthCellRender + ? monthCellRender(monthDate) + : localedMonth; + rowList.push( + +
    {monthCellContent}
    + + ); + counter++; + } + monthList.push( + + {rowList} + + ); + } + + return ( + + + {monthList} + +
    + ); + } +} + +export default MonthTable; diff --git a/components/calendar/table/year-table.jsx b/components/calendar/table/year-table.jsx deleted file mode 100644 index f28065979f..0000000000 --- a/components/calendar/table/year-table.jsx +++ /dev/null @@ -1,101 +0,0 @@ -import React from 'react'; -import classnames from 'classnames'; -import Icon from '../../icon'; -import { isDisabledDate, YEAR_TABLE_COL_COUNT, YEAR_TABLE_ROW_COUNT } from '../utils'; - -class YearTable extends React.PureComponent { - onYearCellClick(date) { - this.props.onSelectYear(date, 'month'); - } - - render() { - const { - prefix, - value, - today, - visibleMonth, - locale, - disabledDate, - goPrevDecade, - goNextDecade, - yearCellRender, - } = this.props; - const currentYear = today.year(); - const selectedYear = value ? value.year() : null; - const visibleYear = visibleMonth.year(); - const startYear = Math.floor(visibleYear / 10) * 10; - - const yearElements = []; - let counter = 0; - - const lastRowIndex = YEAR_TABLE_ROW_COUNT - 1; - const lastColIndex = YEAR_TABLE_COL_COUNT - 1; - - for (let i = 0; i < YEAR_TABLE_ROW_COUNT; i++) { - const rowElements = []; - for (let j = 0; j < YEAR_TABLE_COL_COUNT; j++) { - let content; - let year; - let isDisabled = false; - let onClick; - let title; - - if (i === 0 && j === 0) { - title = locale.prevDecade; - onClick = goPrevDecade; - content = ; - } else if (i === lastRowIndex && j === lastColIndex) { - title = locale.nextDecade; - onClick = goNextDecade; - content = ; - } else { - year = startYear + counter++; - title = year; - const yearDate = visibleMonth.clone().year(year); - isDisabled = isDisabledDate(yearDate, disabledDate, 'year'); - - !isDisabled && (onClick = this.onYearCellClick.bind(this, yearDate)); - - content = yearCellRender ? yearCellRender(yearDate) : year; - } - - const isSelected = year === selectedYear; - - const classNames = classnames({ - [`${prefix}calendar-cell`]: true, - [`${prefix}calendar-cell-current`]: year === currentYear, - [`${prefix}selected`]: isSelected, - [`${prefix}disabled`]: isDisabled, - }); - - rowElements.push( - -
    - {content} -
    - - ); - } - yearElements.push( - - {rowElements} - - ); - } - return ( - - - {yearElements} - -
    - ); - } -} - -export default YearTable; diff --git a/components/calendar/table/year-table.tsx b/components/calendar/table/year-table.tsx new file mode 100644 index 0000000000..bc3cc2b34d --- /dev/null +++ b/components/calendar/table/year-table.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import classnames from 'classnames'; +import { type Moment } from 'moment'; +import Icon from '../../icon'; +import { isDisabledDate, YEAR_TABLE_COL_COUNT, YEAR_TABLE_ROW_COUNT } from '../utils'; +import { type YearTableProps } from '../types'; + +class YearTable extends React.PureComponent { + onYearCellClick(date: Moment) { + this.props.onSelectYear(date, 'month'); + } + + render() { + const { + prefix, + value, + today, + visibleMonth, + locale, + disabledDate, + goPrevDecade, + goNextDecade, + yearCellRender, + } = this.props; + const currentYear = today.year(); + const selectedYear = value ? value.year() : null; + const visibleYear = visibleMonth.year(); + const startYear = Math.floor(visibleYear / 10) * 10; + + const yearElements = []; + let counter = 0; + + const lastRowIndex = YEAR_TABLE_ROW_COUNT - 1; + const lastColIndex = YEAR_TABLE_COL_COUNT - 1; + + for (let i = 0; i < YEAR_TABLE_ROW_COUNT; i++) { + const rowElements = []; + for (let j = 0; j < YEAR_TABLE_COL_COUNT; j++) { + let content; + let year; + let isDisabled = false; + let onClick; + let title; + + if (i === 0 && j === 0) { + title = locale.prevDecade; + onClick = goPrevDecade; + content = ; + } else if (i === lastRowIndex && j === lastColIndex) { + title = locale.nextDecade; + onClick = goNextDecade; + content = ; + } else { + year = startYear + counter++; + title = year; + const yearDate = visibleMonth.clone().year(year); + isDisabled = isDisabledDate(yearDate, disabledDate, 'year'); + + !isDisabled && (onClick = this.onYearCellClick.bind(this, yearDate)); + + content = yearCellRender ? yearCellRender(yearDate) : year; + } + + const isSelected = year === selectedYear; + + const classNames = classnames({ + [`${prefix}calendar-cell`]: true, + [`${prefix}calendar-cell-current`]: year === currentYear, + [`${prefix}selected`]: isSelected, + [`${prefix}disabled`]: isDisabled, + }); + + rowElements.push( + +
    + {content} +
    + + ); + } + yearElements.push( + + {rowElements} + + ); + } + return ( + + + {yearElements} + +
    + ); + } +} + +export default YearTable; diff --git a/components/calendar/types.ts b/components/calendar/types.ts new file mode 100644 index 0000000000..3eda3a1ad1 --- /dev/null +++ b/components/calendar/types.ts @@ -0,0 +1,408 @@ +import type React from 'react'; +import type { Moment, MomentInput, Locale as MomentLocale } from 'moment'; +import type { CommonProps } from '../util'; +import { type Locale } from '../locale/types'; + +interface HTMLAttributesWeak + extends Omit, 'defaultValue' | 'select' | 'onSelect'> {} + +/** + * @api + */ +export type CalendarMode = 'date' | 'month' | 'year'; + +/** + * @api + * @order 1 + */ +export interface CalendarProps + extends Omit, + Omit { + /** + * 默认选中的日期(moment 对象) + * @en Default selected date (moment object) + */ + defaultValue?: Moment | null; + + /** + * 展现形态 + * @en Display shape + * @defaultValue 'fullscreen' + */ + shape?: 'card' | 'fullscreen' | 'panel'; + + /** + * 选中的日期值 (moment 对象) + * @en Selected date value (moment object) + */ + value?: Moment | null; + + /** + * 面板模式 + * @en Panel mode + */ + mode?: CalendarMode; + + /** + * 是否展示非本月的日期 + * @en Whether to show dates outside the current month + * @defaultValue true + */ + showOtherMonth?: boolean; + + /** + * 默认展示的月份 + * @en Default displayed month + */ + defaultVisibleMonth?: () => Moment | null; + /** + * 面板模式变化时的回调 + * @en Callback when the panel mode changes + * @param mode - 对应面板模式 date, month, year + */ + onModeChange?: (mode: CalendarMode) => void; + + /** + * 选择日期单元格时的回调 + * @en Callback when selecting a date cell + */ + onSelect?: (value: Moment) => void; + + /** + * 展现的月份变化时的回调 + * @en Callback when the displayed month changes + */ + onVisibleMonthChange?: (value: Moment, reason: VisibleMonthChangeType) => void; + + /** + * 自定义日期渲染函数 + * @en Customize date rendering function + * @defaultValue value =\> value.date() + */ + dateCellRender?: (value: Moment) => React.ReactNode; + + /** + * 自定义月份渲染函数 + * @en Customize month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 兼容 0.x yearCellRender + * @deprecated use monthCellRender/dateCellRender instead + * @skip + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 不可选择的日期 + * @en Disabled date + */ + disabledDate?: (calendarDate: Moment, view: CalendarMode) => boolean; + + /** + * 面板可变化的模式列表,仅初始化时接收一次 + * @en Panel mode list that can be changed, only received once at initialization + * @defaultValue ['date', 'month', 'year'] + */ + modes?: CalendarMode[]; + /** + * 禁用更改面板模式,采用 dropdown 的方式切换显示日期 (暂不正式对外透出) + * @en Disable changing panel mode, use the dropdown method to switch displayed dates + * @defaultValue false + * @skip + */ + disableChangeMode?: boolean; + /** + * 日期值的格式(用于日期 title 显示的格式) + * @en Date value format(for date title display format) + * @defaultValue 'YYYY-MM-DD' + */ + format?: string; + /** + * 多语言文案 + * @en International text + * @skip + */ + locale?: Locale['Calendar']; + /** + * 年份范围,[START_YEAR, END_YEAR] (只在 shape 为‘card’, 'fullscreen' 下生效) + * @en Year range, [START_YEAR, END_YEAR] (only effective when shape is 'card', 'fullscreen') + */ + yearRange?: [start: number, end: number]; + + /** + * @deprecated use disabledDate instead + * @skip + */ + disabledMonth?: unknown; + /** + * @deprecated use disabledDate instead + * @skip + */ + disabledYear?: unknown; + + /** + * @deprecated use shape instead + * @skip + */ + type?: CalendarProps['shape']; + + /** + * @deprecated use onSelect instead + * @skip + */ + onChange?: (options: { mode: CalendarMode; value: Moment }) => void; + + /** + * @deprecated use defaultVisibleMonth instead + * @skip + */ + base?: MomentInput; +} +/** + * @api Calendar.RangeCalendar + * @order 2 + */ +export interface RangeCalendarProps extends HTMLAttributesWeak, Omit { + /** + * 多语言文案 + * @skip + */ + locale?: Locale['Calendar']; + /** + * 面板模式 + * @en Panel mode + * @defaultValue 'date' + */ + mode?: CalendarMode; + /** + * 禁用更改面板模式,采用 dropdown 的方式切换显示日期 (暂不正式对外透出) + * @en Disable changing panel mode, use the dropdown method to switch displayed dates + * @defaultValue false + * @skip + */ + disableChangeMode?: boolean; + /** + * 日期值的格式(用于日期 title 显示的格式) + * @en Date value format(for date title display format) + * @defaultValue 'YYYY-MM-DD' + */ + format?: string; + /** + * 自定义日期渲染函数 + * @en Customize date rendering function + * @defaultValue value =\> value.date() + */ + dateCellRender?: (value: Moment) => React.ReactNode; + /** + * 选择日期单元格时的回调 + * @en Callback when selecting a date cell + */ + onSelect?: (value: Moment) => void; + /** + * 展现的月份变化时的回调 + * @en Callback when the displayed month changes + */ + onVisibleMonthChange?: (value: Moment, reason: VisibleMonthChangeType) => void; + /** + * 是否展示非本月的日期 + * @en Whether to show dates outside the current month + * @defaultValue true + */ + showOtherMonth?: boolean; + /** + * 开始日期(moment 对象) + * @en Start date (moment object) + */ + startValue?: Moment | null; + /** + * 结束日期(moment 对象) + * @en End date (moment object) + */ + endValue?: Moment | null; + /** + * 默认的开始日期(moment 对象) + * @en Default start date (moment object) + */ + defaultStartValue?: Moment | null; + /** + * 默认的结束日期(moment 对象) + * @en Default end date (moment object) + */ + defaultEndValue?: Moment | null; + /** + * 自定义月份渲染函数 + * @en Customize month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 默认展示的月份 + * @en Default displayed month + */ + defaultVisibleMonth?: () => Moment | null; + /** + * 兼容 0.x yearCellRender + * @deprecated use monthCellRender/dateCellRender instead + * @skip + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 不可选择的日期 + * @en Disabled date + */ + disabledDate?: (calendarDate: Moment, view: CalendarMode) => boolean; + /** + * 展现形态 + * @en Display shape + */ + shape?: 'card' | 'fullscreen' | 'panel'; + /** + * 年份范围,[START_YEAR, END_YEAR] (只在 shape 为‘card’, 'fullscreen' 下生效) + * @en Year range, [START_YEAR, END_YEAR] (only effective when shape is 'card', 'fullscreen') + */ + yearRange?: [number, number]; +} + +export interface MomentLocaleLike + extends Omit< + MomentLocale, + 'monthsShort' | 'months' | 'firstDayOfWeek' | 'weekdays' | 'weekdaysShort' | 'weekdaysMin' + > { + monthsShort: () => string[]; + months: () => string[]; + firstDayOfWeek: () => number; + weekdays: () => string[]; + weekdaysShort: () => string[]; + weekdaysMin: () => string[]; +} + +interface CommonTableProps { + visibleMonth: Moment; + today: Moment; + momentLocale: MomentLocaleLike; +} + +export interface DateTableProps + extends Pick< + Required, + 'dateCellRender' | 'showOtherMonth' | 'format' | 'value' | 'locale' + >, + Pick, + Pick, + Omit, + CommonTableProps { + onSelectDate: (value: Moment, e: React.MouseEvent) => void; +} + +export interface DateTableHeadProps extends CommonProps { + momentLocale: MomentLocaleLike; +} + +export interface MonthTableProps + extends Pick, 'value' | 'locale'>, + Pick, + Omit, + CommonTableProps { + onSelectMonth: (value: Moment, mode: 'date') => void; +} + +export interface YearTableProps + extends Pick, 'value' | 'locale'>, + Pick, + Omit, + CommonTableProps { + onSelectYear: (value: Moment, mode: 'month') => void; + goPrevDecade: () => void; + goNextDecade: () => void; +} + +export interface CardHeaderProps + extends Pick, 'yearRange' | 'locale' | 'mode' | 'showOtherMonth'>, + Omit { + yearRangeOffset?: number; + momentLocale: MomentLocaleLike; + changeMode: (mode: CalendarMode) => void; + visibleMonth: Moment; + changeVisibleMonth: (value: Moment, type: VisibleMonthChangeType) => void; +} + +export interface RangePanelHeaderProps + extends Pick, 'locale' | 'disableChangeMode'>, + Pick, + Omit { + startVisibleMonth: Moment; + endVisibleMonth: Moment; + yearRangeOffset?: number; + momentLocale: MomentLocaleLike; + changeMode: (mode: CalendarMode, type: 'start' | 'end') => void; + goNextMonth: () => void; + goNextYear: () => void; + goPrevMonth: () => void; + goPrevYear: () => void; + changeVisibleMonth: (value: Moment, type: VisibleMonthChangeType) => void; +} + +export interface DatePanelHeaderProps + extends Pick< + Required, + 'locale' | 'disableChangeMode' | 'yearRange' | 'showOtherMonth' + >, + Omit { + goNextMonth: () => void; + goNextYear: () => void; + goPrevMonth: () => void; + goPrevYear: () => void; + changeMode: (mode: CalendarMode, type: 'start' | 'end') => void; + momentLocale: MomentLocaleLike; + visibleMonth: Moment; + yearRangeOffset: number; + changeVisibleMonth: (value: Moment, type: VisibleMonthChangeType) => void; +} + +export interface SelectMenuProps extends CommonProps { + dataSource: { value: React.Key; label: React.ReactNode }[]; + onChange: (value: number) => void; + value: string | number; + className?: string; +} + +export interface MonthPanelHeaderProps + extends Pick, 'locale'>, + Omit { + goNextYear: () => void; + goPrevYear: () => void; + changeMode: (mode: CalendarMode) => void; + visibleMonth: Moment; +} + +export interface YearPanelHeaderProps + extends Pick, 'locale'>, + Omit { + goPrevDecade: () => void; + goNextDecade: () => void; + visibleMonth: Moment; +} + +/** + * @api + */ +export type VisibleMonthChangeType = 'cellClick' | 'buttonClick' | 'yearSelect' | 'monthSelect'; + +export interface CalendarState { + value: Moment | null; + visibleMonth: Moment; + mode: CalendarMode; + MODES: CalendarMode[]; +} + +export interface RangeCalendarState { + startValue: Moment | null; + startVisibleMonth: Moment; + endValue: Moment | null; + prevMode?: CalendarMode; + mode?: CalendarMode; + lastMode?: CalendarMode; + activePanel?: 'start' | 'end'; + lastPanelType: 'start' | 'end'; +} diff --git a/components/calendar/utils/index.js b/components/calendar/utils/index.js deleted file mode 100644 index 29790cf807..0000000000 --- a/components/calendar/utils/index.js +++ /dev/null @@ -1,115 +0,0 @@ -import moment from 'moment'; - -export const DAYS_OF_WEEK = 7; - -export const CALENDAR_TABLE_COL_COUNT = 7; - -export const CALENDAR_TABLE_ROW_COUNT = 6; - -export const MONTH_TABLE_ROW_COUNT = 4; - -export const MONTH_TABLE_COL_COUNT = 3; - -export const YEAR_TABLE_ROW_COUNT = 4; - -export const YEAR_TABLE_COL_COUNT = 3; - -export const CALENDAR_MODE_YEAR = 'year'; - -export const CALENDAR_MODE_MONTH = 'month'; - -export const CALENDAR_MODE_DATE = 'date'; - -export const CALENDAR_MODES = [CALENDAR_MODE_DATE, CALENDAR_MODE_MONTH, CALENDAR_MODE_YEAR]; - -export function isDisabledDate(date, fn, view) { - if (typeof fn === 'function' && fn(date, view)) { - return true; - } - return false; -} - -export function checkMomentObj(props, propName, componentName) { - if (props[propName] && !moment.isMoment(props[propName])) { - return new Error(`Invalid prop ${propName} supplied to ${componentName}. Required a moment object`); - } -} - -export function formatDateValue(value, reservedValue = null) { - if (value && moment.isMoment(value)) { - return value; - } - return reservedValue; -} - -export function getVisibleMonth(defaultVisibleMonth, value) { - let getVM = defaultVisibleMonth; - if (typeof getVM !== 'function' || !moment.isMoment(getVM())) { - getVM = () => { - if (value) { - return value; - } - return moment(); - }; - } - return getVM(); -} - -export function isSameYearMonth(dateA, dateB) { - return dateA.month() === dateB.month() && dateA.year() === dateB.year(); -} - -export function preFormatDateValue(value, format) { - const val = typeof value === 'string' ? moment(value, format, false) : value; - if (val && moment.isMoment(val) && val.isValid()) { - return val; - } - - return null; -} - -export function getLocaleData( - { months, shortMonths, firstDayOfWeek, weekdays, shortWeekdays, veryShortWeekdays }, - localeData -) { - return { - ...localeData, - monthsShort: () => shortMonths || localeData.monthsShort(), - months: () => months || localeData.months(), - firstDayOfWeek: () => firstDayOfWeek || localeData.firstDayOfWeek(), - weekdays: () => weekdays || localeData.weekdays, - weekdaysShort: () => shortWeekdays || localeData.weekdaysShort(), - weekdaysMin: () => veryShortWeekdays || localeData.weekdaysMin(), - }; -} - -/* istanbul ignore next */ -export function getYears(yearRange, yearRangeOffset, year) { - const options = []; - let [startYear, endYear] = yearRange; - if (!startYear || !endYear) { - startYear = year - yearRangeOffset; - endYear = year + yearRangeOffset; - } - - for (let i = startYear; i <= endYear; i++) { - options.push({ - label: i, - value: i, - }); - } - return options; -} - -/* istanbul ignore next */ -export function getMonths(momentLocale) { - const localeMonths = momentLocale.monthsShort(); - const options = []; - for (let i = 0; i < 12; i++) { - options.push({ - value: i, - label: localeMonths[i], - }); - } - return options; -} diff --git a/components/calendar/utils/index.ts b/components/calendar/utils/index.ts new file mode 100644 index 0000000000..4a77895fb1 --- /dev/null +++ b/components/calendar/utils/index.ts @@ -0,0 +1,148 @@ +import moment, { + type MomentInput, + type Moment, + type MomentFormatSpecification, + type Locale as MomentLocale, +} from 'moment'; +import { type CalendarMode, type MomentLocaleLike } from '../types'; + +export const DAYS_OF_WEEK = 7; + +export const CALENDAR_TABLE_COL_COUNT = 7; + +export const CALENDAR_TABLE_ROW_COUNT = 6; + +export const MONTH_TABLE_ROW_COUNT = 4; + +export const MONTH_TABLE_COL_COUNT = 3; + +export const YEAR_TABLE_ROW_COUNT = 4; + +export const YEAR_TABLE_COL_COUNT = 3; + +export const CALENDAR_MODE_YEAR = 'year'; + +export const CALENDAR_MODE_MONTH = 'month'; + +export const CALENDAR_MODE_DATE = 'date'; + +export const CALENDAR_MODES = [ + CALENDAR_MODE_DATE, + CALENDAR_MODE_MONTH, + CALENDAR_MODE_YEAR, +] as CalendarMode[]; + +export function isDisabledDate(date: unknown, fn: unknown, view: unknown) { + if (typeof fn === 'function' && fn(date, view)) { + return true; + } + return false; +} + +export function checkMomentObj( + props: Record, + propName: string, + componentName: string +) { + if (props[propName] && !moment.isMoment(props[propName])) { + return new Error( + `Invalid prop ${propName} supplied to ${componentName}. Required a moment object` + ); + } +} + +export function formatDateValue(value: unknown, reservedValue = null) { + if (value && moment.isMoment(value)) { + return value; + } + return reservedValue; +} + +export function getVisibleMonth(defaultVisibleMonth: () => Moment, value: unknown): Moment; +export function getVisibleMonth(defaultVisibleMonth: unknown, value: V): Moment | NonNullable; +export function getVisibleMonth( + defaultVisibleMonth: unknown, + value: V +): Moment | NonNullable { + let getVM = defaultVisibleMonth; + if (typeof getVM !== 'function' || !moment.isMoment(getVM())) { + getVM = () => { + if (value) { + return value; + } + return moment(); + }; + } + return (getVM as () => Moment | NonNullable)(); +} + +export function isSameYearMonth(dateA: Moment, dateB: Moment) { + return dateA.month() === dateB.month() && dateA.year() === dateB.year(); +} + +export function preFormatDateValue(value: MomentInput | Moment, format: MomentFormatSpecification) { + const val = typeof value === 'string' ? moment(value, format, false) : value; + if (val && moment.isMoment(val) && val.isValid()) { + return val; + } + + return null; +} + +export function getLocaleData( + { + months, + shortMonths, + firstDayOfWeek, + weekdays, + shortWeekdays, + veryShortWeekdays, + }: { + months?: string[]; + shortMonths?: string[]; + firstDayOfWeek?: number; + weekdays?: string[]; + shortWeekdays?: string[]; + veryShortWeekdays?: string[]; + }, + localeData: MomentLocale +): MomentLocaleLike { + return { + ...localeData, + monthsShort: () => shortMonths || localeData.monthsShort(), + months: () => months || localeData.months(), + firstDayOfWeek: () => firstDayOfWeek || localeData.firstDayOfWeek(), + weekdays: () => weekdays || localeData.weekdays(), + weekdaysShort: () => shortWeekdays || localeData.weekdaysShort(), + weekdaysMin: () => veryShortWeekdays || localeData.weekdaysMin(), + }; +} + +export function getYears(yearRange: [number?, number?], yearRangeOffset: number, year: number) { + const options = []; + let [startYear, endYear] = yearRange; + if (!startYear || !endYear) { + startYear = year - yearRangeOffset; + endYear = year + yearRangeOffset; + } + + for (let i = startYear; i <= endYear; i++) { + options.push({ + label: i, + value: i, + }); + } + return options; +} + +export function getMonths(momentLocale: MomentLocaleLike) { + const localeMonths = momentLocale.monthsShort(); + const options = []; + for (let i = 0; i < 12; i++) { + options.push({ + value: i, + label: localeMonths[i], + }); + } + return options; +} diff --git a/components/calendar2/__docs__/demo/basic/index.tsx b/components/calendar2/__docs__/demo/basic/index.tsx index ae21123824..267d53dd93 100644 --- a/components/calendar2/__docs__/demo/basic/index.tsx +++ b/components/calendar2/__docs__/demo/basic/index.tsx @@ -2,10 +2,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; import dayjs from 'dayjs'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; -function onDateChange(value) { +const onDateChange: CalendarProps['onSelect'] = value => { console.log(value.format('L')); -} +}; ReactDOM.render(
    diff --git a/components/calendar2/__docs__/demo/card/index.tsx b/components/calendar2/__docs__/demo/card/index.tsx index 4fe1227df2..b9c1446c6b 100644 --- a/components/calendar2/__docs__/demo/card/index.tsx +++ b/components/calendar2/__docs__/demo/card/index.tsx @@ -1,10 +1,11 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; -function onDateChange(value) { +const onDateChange: CalendarProps['onSelect'] = value => { console.log(value); -} +}; ReactDOM.render(
    diff --git a/components/calendar2/__docs__/demo/custom-cell/index.tsx b/components/calendar2/__docs__/demo/custom-cell/index.tsx index 121ebf10e7..e4b3eae730 100644 --- a/components/calendar2/__docs__/demo/custom-cell/index.tsx +++ b/components/calendar2/__docs__/demo/custom-cell/index.tsx @@ -2,16 +2,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; import dayjs from 'dayjs'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; const currentDate = dayjs(); -function dateCellRender(date) { +const dateCellRender: CalendarProps['dateCellRender'] = date => { const dateNum = date.date(); if (currentDate.month() !== date.month()) { return dateNum; } - let eventList; + let eventList: { type: 'primary' | 'normal'; content: string }[] = []; switch (dateNum) { case 1: eventList = [ @@ -49,9 +50,9 @@ function dateCellRender(date) {
    ); -} +}; -function monthCellRender(date) { +const monthCellRender: CalendarProps['monthCellRender'] = date => { if (currentDate.month() === date.month()) { return (
    @@ -61,7 +62,7 @@ function monthCellRender(date) { ); } return date.month(); -} +}; ReactDOM.render( , diff --git a/components/calendar2/__docs__/demo/default-visible-month/index.tsx b/components/calendar2/__docs__/demo/default-visible-month/index.tsx index a8a36dc553..c245a703ce 100644 --- a/components/calendar2/__docs__/demo/default-visible-month/index.tsx +++ b/components/calendar2/__docs__/demo/default-visible-month/index.tsx @@ -2,19 +2,20 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; import dayjs from 'dayjs'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; -function onSelect(value) { +const onSelect: CalendarProps['onSelect'] = value => { console.log(value.format('L')); -} +}; -function onPanelChange(value, reason) { +const onPanelChange: CalendarProps['onPanelChange'] = (value, reason) => { console.log('Visible month changed to %s from <%s>', value.format('YYYY-MM'), reason); -} +}; ReactDOM.render( dayjs('2018-01', 'YYYY-MM', true)} + defaultPanelValue={dayjs('2018-01', 'YYYY-MM', true)} onPanelChange={onPanelChange} />, mountNode diff --git a/components/calendar2/__docs__/demo/disabled/index.tsx b/components/calendar2/__docs__/demo/disabled/index.tsx index 8fc1c6eb7f..d4c324c3b8 100644 --- a/components/calendar2/__docs__/demo/disabled/index.tsx +++ b/components/calendar2/__docs__/demo/disabled/index.tsx @@ -2,9 +2,10 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; import dayjs from 'dayjs'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; const currentDate = dayjs(); -const disabledDate = function (date) { +const disabledDate: CalendarProps['disabledDate'] = function (date) { return date.valueOf() > currentDate.valueOf(); }; diff --git a/components/calendar2/__docs__/demo/lunar/index.tsx b/components/calendar2/__docs__/demo/lunar/index.tsx index 1f43726151..e39e86eabd 100644 --- a/components/calendar2/__docs__/demo/lunar/index.tsx +++ b/components/calendar2/__docs__/demo/lunar/index.tsx @@ -3,12 +3,13 @@ import ReactDOM from 'react-dom'; import { Calendar2 } from '@alifd/next'; import dayjs from 'dayjs'; import solarLunar from 'solarlunar'; +import type { CalendarProps } from '@alifd/next/types/calendar2'; -function onDateChange(value) { +const onDateChange: CalendarProps['onSelect'] = value => { console.log(value.format()); -} +}; -function dateCellRender(value) { +const dateCellRender: CalendarProps['dateCellRender'] = value => { const solar2lunarData = solarLunar.solar2lunar(value.year(), value.month(), value.date()); return ( @@ -19,7 +20,7 @@ function dateCellRender(value) {
    ); -} +}; ReactDOM.render(
    diff --git a/components/calendar2/__docs__/index.en-us.md b/components/calendar2/__docs__/index.en-us.md index f43df8bf99..738bc1d667 100644 --- a/components/calendar2/__docs__/index.en-us.md +++ b/components/calendar2/__docs__/index.en-us.md @@ -19,7 +19,7 @@ Calendar could be used to display dates, such as schedules, timetables, price ca Calendar use dayjs as a core part to manipulate and display time values. For real usage, it could be used with the latest `dayjs` package. Setting dayjs's locale by: -````js +```js import { DatePicker2, ConfigProvider } from '@alifd/next'; import 'dayjs/locale/en'; import en from '@alifd/next/lib/locale/en-us'; @@ -27,29 +27,74 @@ import en from '@alifd/next/lib/locale/en-us'; function App() { return ( - + ); } ReactDOM.render(, mountNode); -```` +``` ## API ### Calendar -| Param | Description | Type | Default Value | -| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | ----------------------- | -| defaultValue | Default value of calendar | custom | - | -| shape | Shape of calendar

    **option**:
    'card', 'fullscreen', 'panel' | Enum | 'fullscreen' | -| value | Value of calendar | custom | - | -| mode | Mode of panel

    **option**:
    'date', 'month', 'year' | Enum | 'date' | -| showOtherMonth | Show dates of other month in current date | Boolean | true | -| defaultVisibleMonth | Default visible month of panel

    **signature**:
    Function() => void | Function | - | -| onSelect | Callback when select a date

    **signature**:
    Function(value: Object) => void
    **parameter**:
    _value_: {Object} date object | Function | func.noop | -| onModeChange | Callback when change mode

    **签名**:
    Function(mode: string) => void
    **参数**:
    _mode_: {string} mode type: date month year | Function | func.noop | -| dateCellRender | Render function for date cell

    **signature**:
    Function(value: Object) => ReactNode
    **parameter**:
    _value_: {Object} date object
    **return**:
    {ReactNode} null
    | Function | (value) => value.date() | -| monthCellRender | Render function for month cell

    **signature**:
    Function(calendarDate: Object) => ReactNode
    **parameter**:
    _calendarDate_: {Object} current date object
    **return**:
    {ReactNode} null
    | Function | - | -| yearRange | Year Range,[START_YEAR, END_YEAR] \(only shape in ‘card’, 'fullscreen') | Array<Number> | - | -| disabledDate | Function to disable dates

    **signature**:
    Function(calendarDate: Object) => Boolean
    **parameter**:
    _calendarDate_: {Object} current date object
    _view_: {Enum} current view type: 'year', 'month', 'date'
    **return**:
    {Boolean} null
    | Function | - | +| Param | Description | Type | Default Value | Required | +| ----------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -------- | +| defaultValue | Default selected date (dayjs object) | ConfigType | - | | +| value | Selected date value (dayjs object) | ConfigType | - | | +| defaultPanelValue | Default displayed date | ConfigType | - | | +| panelValue | Displayed date | ConfigType | - | | +| shape | Display shape | 'card' \| 'fullscreen' \| 'panel' | 'fullscreen' | | +| mode | Date mode | CalendarMode | 'month' | | +| panelMode | Panel mode, will be inferred automatically if not specified | CalendarPanelMode | - | | +| onSelect | Callback when selecting a date cell | (value: Dayjs, strVal: string) => void | - | | +| onChange | Callback when value changes | (value: Dayjs, strVal: string) => void | - | | +| onPanelChange | Callback when date panel changes | (value: Dayjs, mode: string, reason?: string) => void | - | | +| className | Custom style class | string | - | | +| dateCellRender | Custom date rendering | CustomCellRender | - | | +| monthCellRender | Custom month rendering function | CustomCellRender | - | | +| yearCellRender | Custom year rendering function | CustomCellRender | - | | +| quarterCellRender | Custom quarter rendering function | CustomCellRender | - | | +| disabledDate | Disabled date | (value: Dayjs, mode: CalendarPanelMode) => boolean | - | | +| onPrev | Callback when clicking the left single arrow | OnPrevOrNext | - | | +| onNext | Callback when clicking the right single arrow | OnPrevOrNext | - | | +| onSuperPrev | Callback when clicking the left double arrow | OnPrevOrNext | - | | +| onSuperNext | Callback when clicking the right double arrow | OnPrevOrNext | - | | +| headerRender | Header custom rendering | (props: HeaderPanelProps) => React.ReactNode | - | | +| validValue | Valid year range | [Dayjs, Dayjs] | - | | +| renderHeaderExtra | Render header extra content | (props: HeaderPanelProps) => React.ReactNode | - | | +| cellClassName | Cell custom style | (value: Dayjs) => Record\ \| undefined \| null | - | | +| cellProps | Cell custom property | {
    onMouseEnter?: (
    v: Dayjs,
    e: React.MouseEvent\,
    args: Pick\
    ) => void;
    onMouseLeave?: (
    v: Dayjs,
    e: React.MouseEvent\,
    args: Pick\
    ) => void;
    } | - | | +### CellData + +| Param | Description | Type | Default Value | Required | +| --------- | ----------- | ---------------- | ------------- | -------- | +| value | - | Dayjs | - | yes | +| label | - | number \| string | - | yes | +| isCurrent | - | boolean | - | yes | +| key | - | string \| number | - | yes | + +### OnPrevOrNext + +```typescript +export type OnPrevOrNext = (value: Dayjs, options: { unit: ManipulateType; num: number }) => void; +``` + +### CalendarMode + +```typescript +export type CalendarMode = 'month' | 'year'; +``` + +### CalendarPanelMode + +```typescript +export type CalendarPanelMode = 'date' | 'week' | 'month' | 'quarter' | 'year' | 'decade'; +``` + +### CustomCellRender + +```typescript +export type CustomCellRender = (value: Dayjs) => React.ReactNode; +``` diff --git a/components/calendar2/__docs__/index.md b/components/calendar2/__docs__/index.md index 14546b19c3..92acc2db4f 100644 --- a/components/calendar2/__docs__/index.md +++ b/components/calendar2/__docs__/index.md @@ -10,6 +10,7 @@ 按照日历形式展示数据的容器。 ### 何时使用 + 1.22版本增加当前组件 日历组件是一个偏向于展示与受控的基础组件,可用于日程、课表、价格日历、农历展示等。 @@ -18,7 +19,7 @@ 由于 `Calendar` 组件内部使用 `dayjs` 对象来设置日期(请使用最新版 dayjs),部分 `Locale` 读取自 [日期库`dayjs`的国际化](https://dayjs.gitee.io/docs/zh-CN/i18n/i18n)。 -````js +```js import { DatePicker2, ConfigProvider } from '@alifd/next'; import 'dayjs/locale/en'; import en from '@alifd/next/lib/locale/en-us'; @@ -26,29 +27,74 @@ import en from '@alifd/next/lib/locale/en-us'; function App() { return ( - + ); } ReactDOM.render(, mountNode); -```` +``` ## API ### Calendar -| 参数 | 说明 | 类型 | 默认值 | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | --------------------- | -| defaultValue | 默认选中的日期(dayjs 对象) | custom | - | -| shape | 展现形态

    **可选值**:
    'card', 'fullscreen', 'panel' | Enum | 'fullscreen' | -| value | 选中的日期值 (dayjs 对象) | custom | - | -| mode | 面板模式 | Enum | - | -| showOtherMonth | 是否展示非本月的日期 | Boolean | true | -| defaultVisibleMonth | 默认展示的月份

    **签名**:
    Function() => void | Function | - | -| onSelect | 选择日期单元格时的回调

    **签名**:
    Function(value: Object) => void
    **参数**:
    _value_: {Object} 对应的日期值 (dayjs 对象) | Function | func.noop | -| onModeChange | 面板模式变化时的回调

    **签名**:
    Function(mode: String) => void
    **参数**:
    _mode_: {String} 对应面板模式 date month year | Function | func.noop | -| onVisibleMonthChange | 展现的月份变化时的回调

    **签名**:
    Function(value: Object, reason: String) => void
    **参数**:
    _value_: {Object} 显示的月份 (dayjs 对象)
    _reason_: {String} 触发月份改变原因 | Function | func.noop | -| dateCellRender | 自定义日期渲染函数

    **签名**:
    Function(value: Object) => ReactNode
    **参数**:
    _value_: {Object} 日期值(dayjs对象)
    **返回值**:
    {ReactNode} null
    | Function | value => value.date() | -| monthCellRender | 自定义月份渲染函数

    **签名**:
    Function(calendarDate: Object) => ReactNode
    **参数**:
    _calendarDate_: {Object} 对应 Calendar 返回的自定义日期对象
    **返回值**:
    {ReactNode} null
    | Function | - | -| yearRange | 年份范围,[START_YEAR, END_YEAR] \(只在shape 为 ‘card’, 'fullscreen' 下生效) | Array<Number> | - | -| disabledDate | 不可选择的日期

    **签名**:
    Function(calendarDate: Object, view: String) => Boolean
    **参数**:
    _calendarDate_: {Object} 对应 Calendar 返回的自定义日期对象
    _view_: {String} 当前视图类型,year: 年, month: 月, date: 日
    **返回值**:
    {Boolean} null
    | Function | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ----------------- | -------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------ | -------- | +| defaultValue | 默认选中的日期(dayjs 对象) | ConfigType | - | | +| value | 选中的日期值 (dayjs 对象) | ConfigType | - | | +| defaultPanelValue | 面板默认显示的日期 | ConfigType | - | | +| panelValue | 面板显示的日期(受控) | ConfigType | - | | +| shape | 展现形态 | 'card' \| 'fullscreen' \| 'panel' | 'fullscreen' | | +| mode | 日期模式 | CalendarMode | 'month' | | +| panelMode | 面板模式,未指定时会根据 mode 自动推断 | CalendarPanelMode | - | | +| onSelect | 选择日期单元格时的回调 | (value: Dayjs, strVal: string) => void | - | | +| onChange | 值改变时的回调 | (value: Dayjs, strVal: string) => void | - | | +| onPanelChange | 日期面板变化回调 | (value: Dayjs, mode: string, reason?: string) => void | - | | +| className | 自定义样式类 | string | - | | +| dateCellRender | 自定义日期渲染 | CustomCellRender | - | | +| monthCellRender | 自定义月份渲染函数 | CustomCellRender | - | | +| yearCellRender | 自定义年份渲染函数 | CustomCellRender | - | | +| quarterCellRender | 自定义季度渲染函数 | CustomCellRender | - | | +| disabledDate | 不可选择的日期 | (value: Dayjs, mode: CalendarPanelMode) => boolean | - | | +| onPrev | 点击头部左单箭头时触发的回调 | OnPrevOrNext | - | | +| onNext | 点击头部右单箭头时触发的回调 | OnPrevOrNext | - | | +| onSuperPrev | 点击头部左双箭头时触发的回调 | OnPrevOrNext | - | | +| onSuperNext | 点击头部右双箭头时触发的回调 | OnPrevOrNext | - | | +| headerRender | 头部自定义渲染 | (props: HeaderPanelProps) => React.ReactNode | - | | +| validValue | 可选择的年份的有效区间 | [Dayjs, Dayjs] | - | | +| renderHeaderExtra | 渲染头部额外内容 | (props: HeaderPanelProps) => React.ReactNode | - | | +| cellClassName | 单元格自定义样式 | (value: Dayjs) => Record\ \| undefined \| null | - | | +| cellProps | 单元格自定义属性 | {
    onMouseEnter?: (
    v: Dayjs,
    e: React.MouseEvent\,
    args: Pick\
    ) => void;
    onMouseLeave?: (
    v: Dayjs,
    e: React.MouseEvent\,
    args: Pick\
    ) => void;
    } | - | | + +### CellData + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | ---- | ---------------- | ------ | -------- | +| value | - | Dayjs | - | 是 | +| label | - | number \| string | - | 是 | +| isCurrent | - | boolean | - | 是 | +| key | - | string \| number | - | 是 | + +### OnPrevOrNext + +```typescript +export type OnPrevOrNext = (value: Dayjs, options: { unit: ManipulateType; num: number }) => void; +``` + +### CalendarMode + +```typescript +export type CalendarMode = 'month' | 'year'; +``` + +### CalendarPanelMode + +```typescript +export type CalendarPanelMode = 'date' | 'week' | 'month' | 'quarter' | 'year' | 'decade'; +``` + +### CustomCellRender + +```typescript +export type CustomCellRender = (value: Dayjs) => React.ReactNode; +``` diff --git a/components/calendar2/__tests__/a11y-spec.tsx b/components/calendar2/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..ae3de5ed14 --- /dev/null +++ b/components/calendar2/__tests__/a11y-spec.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import Calendar2 from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Calendar A11y', () => { + it('should not have any violations when default', async () => { + await testReact(); + }); + it('should not have any violations when shape', async () => { + await testReact( +
    + + + +
    + ); + }); +}); diff --git a/components/calendar2/__tests__/index-spec.js b/components/calendar2/__tests__/index-spec.js deleted file mode 100644 index 5b08eeeb57..0000000000 --- a/components/calendar2/__tests__/index-spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import dayjs from 'dayjs'; -import Calendar2 from '../index'; - -Enzyme.configure({ - adapter: new Adapter(), -}); -dayjs.locale('zh-cn'); -const defaultVal = dayjs('2017-10-01', 'YYYY-MM-DD', true); - -/* eslint-disable */ -describe('Calendar2', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - describe('render', () => { - it('should render fullscreen calendar with header', () => { - wrapper = mount(); - - assert(wrapper.find('.next-calendar2-header-title')); - }); - }); -}); diff --git a/components/calendar2/__tests__/index-spec.tsx b/components/calendar2/__tests__/index-spec.tsx new file mode 100644 index 0000000000..bc57a23431 --- /dev/null +++ b/components/calendar2/__tests__/index-spec.tsx @@ -0,0 +1,140 @@ +import React from 'react'; +import dayjs from 'dayjs'; +import Calendar2, { type CalendarProps } from '../index'; +import '../style'; + +dayjs.locale('zh-cn'); +const defaultVal = dayjs('2017-10-01', 'YYYY-MM-DD', true); + +describe('Calendar2', () => { + describe('render', () => { + it('should render with default value', () => { + cy.mount(); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-calendar2-cell-selected'); + }); + + it('should render calendar panel', () => { + cy.mount(); + cy.get('.next-calendar2-panel').should('have.length', 1); + }); + + it('should render calendar card', () => { + cy.mount(); + cy.get('.next-calendar2-card').should('have.length', 1); + }); + + it('should render uncontrolled calendar', () => { + cy.mount(); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-calendar2-cell-selected'); + cy.get('td[title="2017-10-02"]').should('exist'); + cy.get('td[title="2017-10-02"]').click(); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-calendar2-cell-selected'); + }); + + it('should render controlled calendar', () => { + cy.mount().as('Demo'); + cy.get('td[title="2017-10-01"]').should('have.class', 'next-calendar2-cell-selected'); + cy.rerender('Demo', { value: defaultVal.clone().add(1, 'days') }); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-calendar2-cell-selected'); + }); + + it('should render controlled calendar with mode', () => { + cy.mount().as('Demo'); + cy.get('.next-calendar2-cell').should('have.length', 12); + }); + + it('should render with disabled dates', () => { + const disabledDateHandler = cy.spy().as('disabledDateHandler'); + const disabledDate: CalendarProps['disabledDate'] = (date, view) => { + disabledDateHandler(view); + return date.valueOf() > defaultVal.valueOf(); + }; + cy.mount(); + cy.get('td[title="2017-10-02"]').should('have.class', 'next-calendar2-cell-disabled'); + cy.get('@disabledDateHandler').should('be.calledWith', 'date'); + }); + + it('should render custom content', () => { + const dateCellRender: CalendarProps['dateCellRender'] = date => { + const dateNum = date.date(); + if (defaultVal.month() !== date.month()) { + return dateNum; + } + + if (dateNum === 1) { + return
    hello world
    ; + } + }; + cy.mount(); + cy.get('td[title="2017-10-01"] div.test').should('have.length', 1); + }); + }); + + describe('action', () => { + it('should change mode', () => { + const onModeChange = cy.spy().as('onModeChange'); + cy.mount(); + cy.get('.next-radio-wrapper input').eq(1).check({ force: true }); + cy.get('td').should('have.length', 12); + cy.get('td[title="2017-01"]').should('have.length', 1); + cy.get('@onModeChange').should('be.calledOnce'); + }); + + it('should change panel mode to month', () => { + cy.mount(); + cy.get('.next-calendar2-header-text-field button').eq(1).click(); + cy.get('.next-calendar2-cell').should('have.length', 12); + cy.get('.next-calendar2-header-text-field button').eq(0).click(); + cy.get('.next-calendar2-header-text-field').should('have.text', '2010-2019'); + }); + + it('should change panel mode to year', () => { + cy.mount(); + cy.get('.next-calendar2-header-text-field button').eq(0).click(); + cy.get('.next-calendar2-cell').should('have.length', 12); + cy.get('.next-calendar2-cell').eq(0).should('have.text', '2009'); + }); + + it('should change visible month', () => { + cy.mount(); + cy.get('.next-calendar2-header-right-btn').eq(0).click(); + cy.get('.next-calendar2-header-text-field button').eq(1).should('have.text', '11月'); + cy.get('.next-calendar2-header-left-btn').eq(1).click(); + cy.get('.next-calendar2-header-text-field button').eq(1).should('have.text', '10月'); + }); + + it('should change visible month by year', () => { + cy.mount(); + cy.get('.next-calendar2-header-right-btn').eq(1).click(); + cy.get('.next-calendar2-header-text-field button').eq(0).should('have.text', '2018年'); + cy.get('.next-calendar2-header-left-btn').eq(0).click(); + cy.get('.next-calendar2-header-text-field button').eq(0).should('have.text', '2017年'); + }); + + it('should change decade', () => { + cy.mount(); + cy.get('.next-calendar2-header-left-btn').click(); + cy.get('.next-calendar2-header-text-field').should('have.text', '1900-1999'); + cy.get('.next-calendar2-header-right-btn').click(); + cy.get('.next-calendar2-header-text-field').should('have.text', '2000-2099'); + }); + + it('should select date', () => { + const onSelectHandler = cy.spy().as('onSelectHandler'); + const onSelect: CalendarProps['onSelect'] = val => { + onSelectHandler(val.format('YYYY-MM-DD')); + }; + cy.mount(); + cy.get('td[title="2017-10-02"]').click(); + cy.get('@onSelectHandler').should('be.calledWith', '2017-10-02'); + }); + + it('should render fullscreen calendar with header', () => { + cy.mount( + + ); + + cy.get('.next-calendar2-header-title').should('exist'); + }); + }); +}); diff --git a/components/calendar2/calendar.jsx b/components/calendar2/calendar.jsx deleted file mode 100644 index 134b111c41..0000000000 --- a/components/calendar2/calendar.jsx +++ /dev/null @@ -1,263 +0,0 @@ -import React from 'react'; -import { polyfill } from 'react-lifecycles-compat'; -import PT from 'prop-types'; -import classnames from 'classnames'; -import defaultLocale from '../locale/zh-cn'; -import { func, datejs, obj } from '../util'; -import SharedPT from './prop-types'; - -import { CALENDAR_MODE, CALENDAR_SHAPE, DATE_PANEL_MODE } from './constant'; -import HeaderPanel from './panels/header-panel'; -import DateTable from './panels/date-table'; - -const { pickProps, pickOthers } = obj; - -// CALENDAR_MODE => DATE_PANEL_MODE -function getPanelMode(mode) { - return mode && (mode === CALENDAR_MODE.YEAR ? DATE_PANEL_MODE.MONTH : DATE_PANEL_MODE.DATE); -} - -function isValueChanged(newVal, oldVal) { - return newVal !== oldVal && !datejs(newVal).isSame(datejs(oldVal)); -} - -class Calendar extends React.Component { - static propTypes = { - rtl: PT.bool, - name: PT.string, - prefix: PT.string, - locale: PT.object, - /** - * 展现形态 - */ - shape: SharedPT.shape, - /* - * 日期模式: month | year - */ - mode: SharedPT.mode, - /** - * 默认选中的日期(受控) - */ - value: SharedPT.date, - /** - * 默认选中的日期 - */ - defaultValue: SharedPT.date, - /** - * 面板显示的日期(受控) - */ - panelValue: SharedPT.date, - /** - * 面板默认显示的日期 - */ - defaultPanelValue: SharedPT.date, - /** - * 不可选择的日期 - */ - disabledDate: PT.func, - /** - * 可显示的日期范围 - */ - validRange: PT.arrayOf(SharedPT.date), - /** - * 自定义日期渲染 - */ - dateCellRender: PT.func, - quarterCellRender: PT.func, - monthCellRender: PT.func, - yearCellRender: PT.func, - /** - * 自定义头部渲染 - */ - headerRender: PT.func, - /** - * 日期变化回调 - */ - onChange: PT.func, - /** - * 点击选择日期回调 - */ - onSelect: PT.func, - /** - * 日期面板变化回调 - */ - onPanelChange: PT.func, - cellProps: PT.object, - cellClassName: PT.oneOfType([PT.func, PT.string]), - panelMode: PT.any, - onPrev: PT.func, - onNext: PT.func, - onSuperPrev: PT.func, - onSuperNext: PT.func, - colNum: PT.number, - }; - - static defaultProps = { - rtl: false, - prefix: 'next-', - locale: defaultLocale.Calendar, - shape: CALENDAR_SHAPE.FULLSCREEN, - mode: CALENDAR_MODE.MONTH, - }; - - constructor(props) { - super(props); - - const { defaultValue, mode, defaultPanelValue = datejs() } = props; - const value = 'value' in props ? props.value : defaultValue; - const panelValue = datejs('panelValue' in props ? props.panelValue : value || defaultPanelValue); - const panelMode = props.panelMode || getPanelMode(mode) || DATE_PANEL_MODE.DATE; - - this.state = { - mode, - value, - panelMode, - panelValue: panelValue.isValid() ? panelValue : datejs(), - }; - } - - static getDerivedStateFromProps(props, state) { - let newState = null; - let value; - let panelValue; - - if ('value' in props && isValueChanged(props.value, state.value)) { - value = props.value; - panelValue = datejs(value); - } - - if ('panelValue' in props) { - panelValue = datejs(props.panelValue); - } - - // panelValue不能是无效值 - if (panelValue) { - panelValue = panelValue.isValid() ? panelValue : datejs(); - newState = { - panelValue, - }; - } - if (value) { - newState.value = value; - } - - return newState; - } - - switchPanelMode = mode => { - const { MONTH, YEAR, DECADE } = DATE_PANEL_MODE; - const originalPanelMode = this.props.panelMode || getPanelMode(mode); - - switch (mode) { - case YEAR: - return MONTH; - case DECADE: - return YEAR; - default: - return originalPanelMode; - } - }; - - shouldSwitchPanelMode = () => { - const { mode, shape } = this.props; - const { panelMode } = this.state; - const originalPanelMode = this.props.panelMode || getPanelMode(mode); - return shape === CALENDAR_SHAPE.PANEL && panelMode !== originalPanelMode; - }; - - onDateSelect = (value, _, { isCurrent }) => { - const { panelMode } = this.state; - const unit = panelMode === 'date' ? 'day' : panelMode; - - if (this.shouldSwitchPanelMode()) { - this.onPanelChange(value, this.switchPanelMode(panelMode), 'DATESELECT_VALUE_SWITCH_MODE'); - } else { - isCurrent || this.onPanelValueChange(value, 'DATESELECT'); - value.isSame(this.state.value, unit) || this.onChange(value); - - func.invoke(this.props, 'onSelect', [value]); - } - }; - - onModeChange = (mode, reason) => { - this.setState({ - mode, - }); - const panelMode = getPanelMode(mode); - - if (this.state.panelMode !== panelMode) { - this.onPanelModeChange(panelMode, reason); - } - }; - - onPanelValueChange = (panelValue, reason) => { - this.onPanelChange(panelValue, this.state.panelMode, reason); - }; - - onPanelModeChange = (panelMode, reason) => { - this.onPanelChange(this.state.panelValue, panelMode, reason); - }; - - onPanelChange = (value, mode, reason) => { - this.setState({ - panelMode: mode, - panelValue: value, - }); - - func.invoke(this.props, 'onPanelChange', [value, mode, reason]); - }; - - onChange = value => { - this.setState({ - value, - panelValue: value, - }); - func.invoke(this.props, 'onChange', [value]); - }; - - render() { - const value = 'value' in this.props ? datejs(this.props.value) : this.state.value; - const { panelMode, mode, panelValue } = this.state; - - const { prefix, shape, rtl, className, ...restProps } = this.props; - - const sharedProps = { - rtl, - prefix, - shape, - value, - panelValue, - }; - - const headerPanelProps = { - ...pickProps(HeaderPanel.propTypes, restProps), - ...sharedProps, - mode, - panelMode, - onPanelValueChange: this.onPanelValueChange, - onModeChange: this.onModeChange, - onPanelModeChange: this.onPanelModeChange, - showModeSwitch: this.props.mode !== CALENDAR_MODE.YEAR, - }; - - const dateTableProps = { - ...pickProps(DateTable.propTypes, restProps), - ...sharedProps, - mode: panelMode, - onSelect: this.onDateSelect, - }; - - const classNames = classnames([`${prefix}calendar2`, `${prefix}calendar2-${shape}`, className]); - - return ( -
    - -
    - -
    -
    - ); - } -} - -export default polyfill(Calendar); diff --git a/components/calendar2/calendar.tsx b/components/calendar2/calendar.tsx new file mode 100644 index 0000000000..74b5c6efc2 --- /dev/null +++ b/components/calendar2/calendar.tsx @@ -0,0 +1,252 @@ +import React, { type UIEvent } from 'react'; +import { polyfill } from 'react-lifecycles-compat'; +import PT from 'prop-types'; +import classnames from 'classnames'; +import { type Dayjs, type ConfigType } from 'dayjs'; +import defaultLocale from '../locale/zh-cn'; +import { func, datejs, obj, type ClassPropsWithDefault } from '../util'; +import SharedPT from './prop-types'; + +import { CALENDAR_MODE, CALENDAR_SHAPE, DATE_PANEL_MODE } from './constant'; +import HeaderPanel from './panels/header-panel'; +import DateTable from './panels/date-table'; +import type { + CalendarMode, + CalendarPanelMode, + CalendarProps, + CalendarState, + CellData, +} from './types'; + +const { pickProps, pickOthers } = obj; + +// CALENDAR_MODE => DATE_PANEL_MODE +function getPanelMode(mode: CalendarMode | CalendarPanelMode) { + return mode && (mode === CALENDAR_MODE.YEAR ? DATE_PANEL_MODE.MONTH : DATE_PANEL_MODE.DATE); +} + +function isValueChanged(newVal: ConfigType, oldVal: ConfigType) { + return newVal !== oldVal && !datejs(newVal).isSame(datejs(oldVal)); +} + +type CalendarPropsWithDefault = ClassPropsWithDefault; + +class Calendar extends React.Component { + static propTypes = { + rtl: PT.bool, + name: PT.string, + prefix: PT.string, + locale: PT.object, + shape: SharedPT.shape, + mode: SharedPT.mode, + value: SharedPT.date, + defaultValue: SharedPT.date, + panelValue: SharedPT.date, + defaultPanelValue: SharedPT.date, + disabledDate: PT.func, + dateCellRender: PT.func, + quarterCellRender: PT.func, + monthCellRender: PT.func, + yearCellRender: PT.func, + headerRender: PT.func, + onChange: PT.func, + onSelect: PT.func, + onPanelChange: PT.func, + cellProps: PT.object, + cellClassName: PT.oneOfType([PT.func, PT.string]), + panelMode: PT.any, + onPrev: PT.func, + onNext: PT.func, + onSuperPrev: PT.func, + onSuperNext: PT.func, + colNum: PT.number, + }; + + static defaultProps = { + rtl: false, + prefix: 'next-', + locale: defaultLocale.Calendar, + shape: CALENDAR_SHAPE.FULLSCREEN, + mode: CALENDAR_MODE.MONTH, + }; + + static displayName = 'Calendar'; + + readonly props: CalendarPropsWithDefault; + + constructor(props: CalendarProps) { + super(props); + + const { defaultValue, mode, defaultPanelValue = datejs() } = props; + const value = 'value' in props ? props.value : defaultValue; + const panelValue = datejs( + 'panelValue' in props ? props.panelValue : value || defaultPanelValue + ); + const panelMode = props.panelMode || getPanelMode(mode!) || DATE_PANEL_MODE.DATE; + + this.state = { + mode: mode!, + value, + panelMode, + panelValue: panelValue.isValid() ? panelValue : datejs(), + }; + } + + static getDerivedStateFromProps(props: CalendarPropsWithDefault, state: CalendarState) { + let newState: Partial = {}; + let value; + let panelValue; + + if ('value' in props && isValueChanged(props.value, state.value)) { + value = props.value; + panelValue = datejs(value); + } + + if ('panelValue' in props) { + panelValue = datejs(props.panelValue); + } + + // panelValue 不能是无效值 + if (panelValue) { + panelValue = panelValue.isValid() ? panelValue : datejs(); + newState = { + panelValue, + }; + } + if (value) { + newState.value = value; + } + + return newState; + } + + switchPanelMode = (mode: CalendarPanelMode) => { + const { MONTH, YEAR, DECADE } = DATE_PANEL_MODE; + const originalPanelMode = this.props.panelMode || getPanelMode(mode); + + switch (mode) { + case YEAR: + return MONTH; + case DECADE: + return YEAR; + default: + return originalPanelMode; + } + }; + + shouldSwitchPanelMode = () => { + const { mode, shape } = this.props; + const { panelMode } = this.state; + const originalPanelMode = this.props.panelMode || getPanelMode(mode); + return shape === CALENDAR_SHAPE.PANEL && panelMode !== originalPanelMode; + }; + + onDateSelect = (value: Dayjs, _: UIEvent, { isCurrent }: Pick) => { + const { panelMode } = this.state; + const unit = panelMode === 'date' ? 'day' : panelMode; + + if (this.shouldSwitchPanelMode()) { + this.onPanelChange( + value, + this.switchPanelMode(panelMode), + 'DATESELECT_VALUE_SWITCH_MODE' + ); + } else { + isCurrent || this.onPanelValueChange(value, 'DATESELECT'); + // @ts-expect-error unit 在这里不能是 quarter 和 week + value.isSame(this.state.value, unit) || this.onChange(value); + + func.invoke(this.props, 'onSelect', [value]); + } + }; + + onModeChange = (mode: CalendarMode, reason?: string) => { + this.setState({ + mode, + }); + const panelMode = getPanelMode(mode); + + if (this.state.panelMode !== panelMode) { + this.onPanelModeChange(panelMode, reason); + } + }; + + onPanelValueChange = (panelValue: Dayjs, reason?: string) => { + this.onPanelChange(panelValue, this.state.panelMode, reason); + }; + + onPanelModeChange = (panelMode: CalendarPanelMode, reason?: string) => { + this.onPanelChange(this.state.panelValue, panelMode, reason); + }; + + onPanelChange = (value: Dayjs, mode: CalendarPanelMode, reason?: string) => { + this.setState({ + panelMode: mode, + panelValue: value, + }); + + func.invoke(this.props, 'onPanelChange', [value, mode, reason]); + }; + + onChange = (value: Dayjs) => { + this.setState({ + value, + panelValue: value, + }); + func.invoke(this.props, 'onChange', [value]); + }; + + render() { + const value = 'value' in this.props ? datejs(this.props.value) : this.state.value; + const { panelMode, mode, panelValue } = this.state; + + const { prefix, shape, rtl, className, ...restProps } = this.props; + + const sharedProps = { + rtl, + prefix, + shape, + value, + panelValue, + }; + + const headerPanelProps = { + ...pickProps(HeaderPanel.propTypes, restProps), + ...sharedProps, + mode, + panelMode, + onPanelValueChange: this.onPanelValueChange, + onModeChange: this.onModeChange, + onPanelModeChange: this.onPanelModeChange, + showModeSwitch: this.props.mode !== CALENDAR_MODE.YEAR, + }; + + const dateTableProps = { + ...pickProps(DateTable.propTypes, restProps), + ...sharedProps, + mode: panelMode, + onSelect: this.onDateSelect, + }; + + const classNames = classnames([ + `${prefix}calendar2`, + `${prefix}calendar2-${shape}`, + className, + ]); + + return ( +
    + +
    + +
    +
    + ); + } +} + +export default polyfill(Calendar); diff --git a/components/calendar2/constant.js b/components/calendar2/constant.js deleted file mode 100644 index 4e86b6de3e..0000000000 --- a/components/calendar2/constant.js +++ /dev/null @@ -1,30 +0,0 @@ -// 日历shape -export const CALENDAR_SHAPE = { - FULLSCREEN: 'fullscreen', - CARD: 'card', - PANEL: 'panel', -}; - -// 日历模式 -export const CALENDAR_MODE = { - MONTH: 'month', - YEAR: 'year', -}; - -// 日期面板的模式 -export const DATE_PANEL_MODE = { - DATE: 'date', - WEEK: 'week', - MONTH: 'month', - QUARTER: 'quarter', - YEAR: 'year', - DECADE: 'decade', -}; - -// 单元格选中状态 -export const CALENDAR_CELL_STATE = { - UN_SELECTED: 0, - SELECTED: 1, - SELECTED_BEGIN: 2, - SELECTED_END: 3, -}; diff --git a/components/calendar2/constant.ts b/components/calendar2/constant.ts new file mode 100644 index 0000000000..49f87cfa25 --- /dev/null +++ b/components/calendar2/constant.ts @@ -0,0 +1,32 @@ +import type { CalendarPanelMode } from './types'; + +// 日历 shape +export const CALENDAR_SHAPE = { + FULLSCREEN: 'fullscreen', + CARD: 'card', + PANEL: 'panel', +}; + +// 日历模式 +export const CALENDAR_MODE = { + MONTH: 'month', + YEAR: 'year', +}; + +// 日期面板的模式 +export const DATE_PANEL_MODE: Record = { + DATE: 'date', + WEEK: 'week', + MONTH: 'month', + QUARTER: 'quarter', + YEAR: 'year', + DECADE: 'decade', +}; + +// 单元格选中状态 +export const CALENDAR_CELL_STATE = { + UN_SELECTED: 0, + SELECTED: 1, + SELECTED_BEGIN: 2, + SELECTED_END: 3, +}; diff --git a/components/calendar2/index.d.ts b/components/calendar2/index.d.ts deleted file mode 100644 index e70d72d8f1..0000000000 --- a/components/calendar2/index.d.ts +++ /dev/null @@ -1,76 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; -import { Dayjs, ConfigType } from 'dayjs'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onSelect?: any; - onChange?: any; -} - -export interface CalendarProps extends HTMLAttributesWeak, CommonProps { - name?: string; - /** - * 默认选中的日期(dayjs 对象) - */ - defaultValue?: ConfigType; - - /** - * 选中的日期值 (dayjs 对象) - */ - value?: ConfigType; - - /** - * 面板默认显示的日期 - */ - defaultPanelValue?: ConfigType; - - /** - * 展现形态 - */ - shape?: 'card' | 'fullscreen' | 'panel'; - - /** - * 选择日期单元格时的回调 - */ - onSelect?: (value: Dayjs, strVal: string) => void; - - /** - * 值改变时的回调 - */ - onChange?: (value: Dayjs, strVal: string) => void; - - /** - * 日期面板变化回调 - */ - onPanelChange?: (value: Dayjs, mode: string) => void; - - /** - * 自定义样式类 - */ - className?: string; - - /** - * 自定义日期渲染 - */ - dateCellRender?: (value: Dayjs) => React.ReactNode; - - /** - * 自定义月份渲染函数 - */ - monthCellRender?: (value: Dayjs) => React.ReactNode; - - /** - * 自定义年份渲染函数 - */ - yearCellRender?: (value: Dayjs) => React.ReactNode; - - /** - * 不可选择的日期 - */ - disabledDate?: (value: Dayjs, mode: string) => boolean; -} - -export default class Calendar extends React.Component {} diff --git a/components/calendar2/index.jsx b/components/calendar2/index.jsx deleted file mode 100644 index ca336a0032..0000000000 --- a/components/calendar2/index.jsx +++ /dev/null @@ -1,4 +0,0 @@ -import ConfigProvider from '../config-provider'; -import Calendar from './calendar'; - -export default ConfigProvider.config(Calendar); diff --git a/components/calendar2/index.tsx b/components/calendar2/index.tsx new file mode 100644 index 0000000000..7911f3b1a9 --- /dev/null +++ b/components/calendar2/index.tsx @@ -0,0 +1,13 @@ +import ConfigProvider from '../config-provider'; +import Calendar from './calendar'; + +export type { + CalendarProps, + CellData, + OnPrevOrNext, + CalendarMode, + CalendarPanelMode, + CustomCellRender, +} from './types'; + +export default ConfigProvider.config(Calendar); diff --git a/components/calendar2/mobile/index.jsx b/components/calendar2/mobile/index.tsx similarity index 100% rename from components/calendar2/mobile/index.jsx rename to components/calendar2/mobile/index.tsx diff --git a/components/calendar2/panels/date-table.jsx b/components/calendar2/panels/date-table.jsx deleted file mode 100644 index 1c7b8c28ce..0000000000 --- a/components/calendar2/panels/date-table.jsx +++ /dev/null @@ -1,353 +0,0 @@ -import React from 'react'; -import { polyfill } from 'react-lifecycles-compat'; -import classnames from 'classnames'; -import PT from 'prop-types'; -import SharedPT from '../prop-types'; -import { DATE_PANEL_MODE } from '../constant'; -import { func, datejs, KEYCODE } from '../../util'; - -const { bindCtx, renderNode } = func; -const { DATE, WEEK, MONTH, QUARTER, YEAR, DECADE } = DATE_PANEL_MODE; - -// 面板行数 -const mode2Rows = { - [DATE]: 7, - [WEEK]: 7, - [MONTH]: 4, - [QUARTER]: 4, - [YEAR]: 4, - [DECADE]: 3, -}; - -class DateTable extends React.Component { - static propTypes = { - mode: SharedPT.panelMode, - value: SharedPT.date, - panelValue: SharedPT.date, - dateCellRender: PT.func, - quarterCellRender: PT.func, - monthCellRender: PT.func, - yearCellRender: PT.func, - disabledDate: PT.func, - selectedState: PT.func, - hoveredState: PT.func, - onSelect: PT.func, - onDateSelect: PT.func, - startOnSunday: PT.bool, - cellClassName: PT.oneOfType([PT.func, PT.string]), - colNum: PT.number, - cellProps: PT.object, - }; - - constructor(props) { - super(props); - - this.prefixCls = `${props.prefix}calendar2`; - - bindCtx(this, [ - 'getDateCellData', - 'getMonthCellData', - 'getQuarterCellData', - 'getYearCellData', - 'getDecadeData', - 'handleKeyDown', - 'handleSelect', - 'handleMouseEnter', - 'handleMouseLeave', - ]); - - this.state = { - hoverValue: null, - }; - } - - handleSelect(v, e, args) { - func.invoke(this.props, 'onSelect', [v, e, args]); - } - - handleKeyDown(v, e, args) { - switch (e.keyCode) { - case KEYCODE.ENTER: - this.handleSelect(v, e, args); - break; - case KEYCODE.RIGHT: - break; - } - // e.preventDefault(); - } - - handleMouseEnter(v, e, args) { - func.invoke(this.props.cellProps, 'onMouseEnter', [v, e, args]); - } - - handleMouseLeave(v, e, args) { - func.invoke(this.props.cellProps, 'onMouseLeave', [v, e, args]); - } - - isSame(curDate, date, mode) { - switch (mode) { - case DATE: - return curDate.isSame(date, 'day'); - case WEEK: - return curDate.isSame(date, 'week'); - case QUARTER: - return curDate.isSame(date, 'quarter'); - case MONTH: - return curDate.isSame(date, 'month'); - case YEAR: - return curDate.isSame(date, 'year'); - case DECADE: { - const curYear = curDate.year(); - const targetYear = date.year(); - return curYear <= targetYear && targetYear < curYear + 10; - } - } - } - - getCustomRender = mode => { - const mode2RenderName = { - [DATE]: 'dateCellRender', - [QUARTER]: 'quarterCellRender', - [MONTH]: 'monthCellRender', - [YEAR]: 'yearCellRender', - }; - - return this.props[mode2RenderName[mode]]; - }; - - /** - * 渲染日期面板 - * @param {Object[]} cellData - 单元格数据 - * @param {String} cellData[].label - 单元格显示文本 - * @param {Object} cellData[].value - 日期对象 - * @param {Boolean} cellData[].isCurrent - 是否是当前面板时间范围内的值 - */ - renderCellContent(cellData) { - const { props } = this; - const { mode, hoveredState, cellClassName } = props; - const { hoverValue } = this.state; - - const cellContent = []; - const cellCls = `${this.prefixCls}-cell`; - - const now = datejs(); - const rowLen = mode2Rows[mode]; - - for (let i = 0; i < cellData.length; ) { - const children = []; - - let isCurrentWeek; - for (let j = 0; j < rowLen; j++) { - const { label, value, key, isCurrent } = cellData[i++]; - const v = value.startOf(mode); - - const isDisabled = props.disabledDate && props.disabledDate(v, mode); - const hoverState = hoverValue && hoveredState && hoveredState(hoverValue); - const className = classnames(cellCls, { - [`${cellCls}-current`]: isCurrent, // 是否属于当前面板值 - [`${cellCls}-today`]: mode === WEEK ? this.isSame(value, now, DATE) : this.isSame(v, now, mode), - [`${cellCls}-selected`]: this.isSame(v, props.value, mode), - [`${cellCls}-disabled`]: isDisabled, - [`${cellCls}-range-hover`]: hoverState, - ...(cellClassName && cellClassName(v)), - }); - - let onEvents = null; - - if (!isDisabled) { - onEvents = { - onClick: e => this.handleSelect(v, e, { isCurrent, label }), - onKeyDown: e => this.handleKeyDown(v, e, { isCurrent, label }), - onMouseEnter: e => this.handleMouseEnter(v, e, { isCurrent, label }), - onMouseLeave: e => this.handleMouseLeave(v, e, { isCurrent, label }), - }; - } - - if (mode === WEEK && j === 0) { - const week = v.week(); - - children.push( - -
    {week}
    - - ); - isCurrentWeek = isCurrent; - } - - const customRender = this.getCustomRender(mode); - - children.push( - -
    - {renderNode(customRender,
    {label}
    , [v])} -
    - - ); - } - - let className; - if (mode === WEEK) { - className = classnames(`${this.prefixCls}-week`, { [`${this.prefixCls}-week-current`]: isCurrentWeek }); - } - - cellContent.push( - - {children} - - ); - } - - return cellContent; - } - - // 星期几 - renderWeekdaysHead() { - let weekdaysMin = datejs.weekdaysMin(); - const firstDayOfWeek = datejs.localeData().firstDayOfWeek(); - - // 默认一周的第一天是周日,否则需要调整 - if (firstDayOfWeek !== 0) { - weekdaysMin = weekdaysMin.slice(firstDayOfWeek).concat(weekdaysMin.slice(0, firstDayOfWeek)); - } - - return ( - - - {/* 占位 */} - {this.props.mode === WEEK ? : null} - {weekdaysMin.map(d => { - return {d}; - })} - - - ); - } - - getDateCellData() { - const { panelValue, colNum } = this.props; - - const firstDayOfMonth = panelValue.clone().startOf('month'); - const weekOfFirstDay = firstDayOfMonth.day(); // 当月第一天星期几 - const daysOfCurMonth = panelValue.endOf('month').date(); // 当月天数 - const firstDayOfWeek = datejs.localeData().firstDayOfWeek(); // 一周的第一天是星期几 - - const cellData = []; - const preDays = (weekOfFirstDay - firstDayOfWeek + 7) % 7; - const nextDays = colNum - ? colNum * mode2Rows[DATE] - preDays - daysOfCurMonth - : (7 - ((preDays + daysOfCurMonth) % 7)) % 7; - - // 上个月日期 - for (let i = preDays; i > 0; i--) { - cellData.push(firstDayOfMonth.clone().subtract(i, 'day')); - } - - // 本月日期 - for (let i = 0; i < daysOfCurMonth; i++) { - cellData.push(firstDayOfMonth.clone().add(i, 'day')); - } - - // 下个月日期 - for (let i = 0; i < nextDays; i++) { - cellData.push(firstDayOfMonth.clone().add(daysOfCurMonth + i, 'day')); - } - - return cellData.map(value => { - return { - value, - label: value.date(), - isCurrent: value.isSame(firstDayOfMonth, 'month'), - key: value.format('YYYY-MM-DD'), - }; - }); - } - - getMonthCellData() { - const { panelValue } = this.props; - - return datejs.monthsShort().map((label, index) => { - const value = panelValue.clone().month(index); - - return { - label, - value, - isCurrent: true, - key: value.format('YYYY-MM'), - }; - }); - } - - getQuarterCellData() { - const { panelValue } = this.props; - - return [1, 2, 3, 4].map(i => { - return { - label: `Q${i}`, - value: panelValue.clone().quarter(i), - isCurrent: true, - key: `Q${i}`, - }; - }); - } - - getYearCellData() { - const { panelValue } = this.props; - const curYear = panelValue.year(); - const startYear = curYear - (curYear % 10) - 1; - const cellData = []; - - for (let i = 0; i < 12; i++) { - const y = startYear + i; - - cellData.push({ - value: panelValue.clone().year(y), - label: y, - isCurrent: i > 0 && i < 11, - key: y, - }); - } - - return cellData; - } - - getDecadeData() { - const { panelValue } = this.props; - const curYear = panelValue.year(); - const startYear = curYear - (curYear % 100) - 10; - const cellData = []; - - for (let i = 0; i < 12; i++) { - const y = startYear + i * 10; - - cellData.push({ - value: panelValue.clone().year(y), - label: `${y}-${y + 9}`, - isCurrent: i > 0 && i < 11, - key: `${y}-${y + 9}`, - }); - } - - return cellData; - } - - render() { - const { mode } = this.props; - const mode2Data = { - [DATE]: this.getDateCellData, - [WEEK]: this.getDateCellData, - [MONTH]: this.getMonthCellData, - [QUARTER]: this.getQuarterCellData, - [YEAR]: this.getYearCellData, - [DECADE]: this.getDecadeData, - }; - - return ( - - {[DATE, WEEK].includes(mode) ? this.renderWeekdaysHead() : null} - {this.renderCellContent(mode2Data[mode]())} -
    - ); - } -} - -export default polyfill(DateTable); diff --git a/components/calendar2/panels/date-table.tsx b/components/calendar2/panels/date-table.tsx new file mode 100644 index 0000000000..075c8f5f83 --- /dev/null +++ b/components/calendar2/panels/date-table.tsx @@ -0,0 +1,386 @@ +import React, { type KeyboardEvent, type MouseEvent } from 'react'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import PT from 'prop-types'; +import { type WeekdayNames, type Dayjs, type ConfigType } from 'dayjs'; +import SharedPT from '../prop-types'; +import { DATE_PANEL_MODE } from '../constant'; +import { func, datejs, KEYCODE } from '../../util'; +import type { CalendarPanelMode, CellData, DateTableProps, DateTableState } from '../types'; + +const { bindCtx, renderNode } = func; +const { DATE, WEEK, MONTH, QUARTER, YEAR, DECADE } = DATE_PANEL_MODE; + +// 面板行数 +const mode2Rows = { + [DATE]: 7, + [WEEK]: 7, + [MONTH]: 4, + [QUARTER]: 4, + [YEAR]: 4, + [DECADE]: 3, +}; + +class DateTable extends React.Component { + static propTypes = { + mode: SharedPT.panelMode, + value: SharedPT.date, + panelValue: SharedPT.date, + dateCellRender: PT.func, + quarterCellRender: PT.func, + monthCellRender: PT.func, + yearCellRender: PT.func, + disabledDate: PT.func, + hoveredState: PT.func, + onSelect: PT.func, + cellClassName: PT.oneOfType([PT.func, PT.string]), + colNum: PT.number, + cellProps: PT.object, + }; + prefixCls: string; + + constructor(props: DateTableProps) { + super(props); + + this.prefixCls = `${props.prefix}calendar2`; + + bindCtx(this, [ + 'getDateCellData', + 'getMonthCellData', + 'getQuarterCellData', + 'getYearCellData', + 'getDecadeData', + 'handleKeyDown', + 'handleSelect', + 'handleMouseEnter', + 'handleMouseLeave', + ]); + + this.state = { + hoverValue: null, + }; + } + + handleSelect( + v: Dayjs, + e: KeyboardEvent | MouseEvent, + args: Pick + ) { + func.invoke(this.props, 'onSelect', [v, e, args]); + } + + handleKeyDown( + v: Dayjs, + e: KeyboardEvent, + args: Pick + ) { + switch (e.keyCode) { + case KEYCODE.ENTER: + this.handleSelect(v, e, args); + break; + case KEYCODE.RIGHT: + break; + } + // e.preventDefault(); + } + + handleMouseEnter( + v: Dayjs, + e: MouseEvent, + args: Pick + ) { + func.invoke(this.props.cellProps, 'onMouseEnter', [v, e, args]); + } + + handleMouseLeave( + v: Dayjs, + e: MouseEvent, + args: Pick + ) { + func.invoke(this.props.cellProps, 'onMouseLeave', [v, e, args]); + } + + isSame(curDate: Dayjs, date: ConfigType, mode: CalendarPanelMode) { + switch (mode) { + case DATE: + return curDate.isSame(date, 'day'); + case WEEK: + return curDate.isSame(date, 'week'); + case QUARTER: + return curDate.isSame(date, 'quarter'); + case MONTH: + return curDate.isSame(date, 'month'); + case YEAR: + return curDate.isSame(date, 'year'); + case DECADE: { + const curYear = curDate.year(); + // @ts-expect-error mode 为 decade 时要求 date 为 dayjs + const targetYear = date.year(); + return curYear <= targetYear && targetYear < curYear + 10; + } + } + } + + getCustomRender = (mode: CalendarPanelMode) => { + const mode2RenderName: Record< + string, + 'dateCellRender' | 'monthCellRender' | 'quarterCellRender' | 'yearCellRender' + > = { + [DATE]: 'dateCellRender', + [QUARTER]: 'quarterCellRender', + [MONTH]: 'monthCellRender', + [YEAR]: 'yearCellRender', + }; + + return this.props[mode2RenderName[mode]]; + }; + + /** + * 渲染日期面板 + * @param cellData - 单元格数据 + */ + renderCellContent(cellData: CellData[]) { + const { props } = this; + const { mode, hoveredState, cellClassName } = props; + const { hoverValue } = this.state; + + const cellContent = []; + const cellCls = `${this.prefixCls}-cell`; + + const now = datejs(); + const rowLen = mode2Rows[mode]; + + for (let i = 0; i < cellData.length; ) { + const children = []; + + let isCurrentWeek; + for (let j = 0; j < rowLen; j++) { + const { label, value, key, isCurrent } = cellData[i++]; + // @ts-expect-error decade/quarter 不能作为 startOf 的参数 + const v = value.startOf(mode); + + const isDisabled = props.disabledDate && props.disabledDate(v, mode); + const hoverState = hoverValue && hoveredState && hoveredState(hoverValue); + const className = classnames(cellCls, { + [`${cellCls}-current`]: isCurrent, // 是否属于当前面板值 + [`${cellCls}-today`]: + mode === WEEK ? this.isSame(value, now, DATE) : this.isSame(v, now, mode), + [`${cellCls}-selected`]: this.isSame(v, props.value, mode), + [`${cellCls}-disabled`]: isDisabled, + [`${cellCls}-range-hover`]: hoverState, + ...(cellClassName && cellClassName(v)), + }); + + let onEvents = null; + + if (!isDisabled) { + onEvents = { + onClick: (e: MouseEvent) => + this.handleSelect(v, e, { isCurrent, label }), + onKeyDown: (e: KeyboardEvent) => + this.handleKeyDown(v, e, { isCurrent, label }), + onMouseEnter: (e: MouseEvent) => + this.handleMouseEnter(v, e, { isCurrent, label }), + onMouseLeave: (e: MouseEvent) => + this.handleMouseLeave(v, e, { isCurrent, label }), + }; + } + + if (mode === WEEK && j === 0) { + const week = v.week(); + + children.push( + +
    {week}
    + + ); + isCurrentWeek = isCurrent; + } + + const customRender = this.getCustomRender(mode); + + children.push( + +
    + {renderNode( + customRender, +
    {label}
    , + [v] + )} +
    + + ); + } + + let className; + if (mode === WEEK) { + className = classnames(`${this.prefixCls}-week`, { + [`${this.prefixCls}-week-current`]: isCurrentWeek, + }); + } + + cellContent.push( + + {children} + + ); + } + + return cellContent; + } + + // 星期几 + renderWeekdaysHead() { + let weekdaysMin = datejs.weekdaysMin(); + const firstDayOfWeek = datejs.localeData().firstDayOfWeek(); + + // 默认一周的第一天是周日,否则需要调整 + if (firstDayOfWeek !== 0) { + weekdaysMin = weekdaysMin + .slice(firstDayOfWeek) + .concat(weekdaysMin.slice(0, firstDayOfWeek)) as WeekdayNames; + } + + return ( + + + {/* 占位 */} + {this.props.mode === WEEK ? ( + + ) : null} + {weekdaysMin.map(d => { + return {d}; + })} + + + ); + } + + getDateCellData() { + const { panelValue, colNum } = this.props; + + const firstDayOfMonth = panelValue.clone().startOf('month'); + const weekOfFirstDay = firstDayOfMonth.day(); // 当月第一天星期几 + const daysOfCurMonth = panelValue.endOf('month').date(); // 当月天数 + const firstDayOfWeek = datejs.localeData().firstDayOfWeek(); // 一周的第一天是星期几 + + const cellData: Dayjs[] = []; + const preDays = (weekOfFirstDay - firstDayOfWeek + 7) % 7; + const nextDays = colNum + ? colNum * mode2Rows[DATE] - preDays - daysOfCurMonth + : (7 - ((preDays + daysOfCurMonth) % 7)) % 7; + + // 上个月日期 + for (let i = preDays; i > 0; i--) { + cellData.push(firstDayOfMonth.clone().subtract(i, 'day')); + } + + // 本月日期 + for (let i = 0; i < daysOfCurMonth; i++) { + cellData.push(firstDayOfMonth.clone().add(i, 'day')); + } + + // 下个月日期 + for (let i = 0; i < nextDays; i++) { + cellData.push(firstDayOfMonth.clone().add(daysOfCurMonth + i, 'day')); + } + + return cellData.map(value => { + return { + value, + label: value.date(), + isCurrent: value.isSame(firstDayOfMonth, 'month'), + key: value.format('YYYY-MM-DD'), + }; + }); + } + + getMonthCellData() { + const { panelValue } = this.props; + + return datejs.monthsShort().map((label, index) => { + const value = panelValue.clone().month(index); + + return { + label, + value, + isCurrent: true, + key: value.format('YYYY-MM'), + }; + }); + } + + getQuarterCellData() { + const { panelValue } = this.props; + + return [1, 2, 3, 4].map(i => { + return { + label: `Q${i}`, + value: panelValue.clone().quarter(i), + isCurrent: true, + key: `Q${i}`, + }; + }); + } + + getYearCellData() { + const { panelValue } = this.props; + const curYear = panelValue.year(); + const startYear = curYear - (curYear % 10) - 1; + const cellData = []; + + for (let i = 0; i < 12; i++) { + const y = startYear + i; + + cellData.push({ + value: panelValue.clone().year(y), + label: y, + isCurrent: i > 0 && i < 11, + key: y, + }); + } + + return cellData; + } + + getDecadeData() { + const { panelValue } = this.props; + const curYear = panelValue.year(); + const startYear = curYear - (curYear % 100) - 10; + const cellData = []; + + for (let i = 0; i < 12; i++) { + const y = startYear + i * 10; + + cellData.push({ + value: panelValue.clone().year(y), + label: `${y}-${y + 9}`, + isCurrent: i > 0 && i < 11, + key: `${y}-${y + 9}`, + }); + } + + return cellData; + } + + render() { + const { mode } = this.props; + const mode2Data = { + [DATE]: this.getDateCellData, + [WEEK]: this.getDateCellData, + [MONTH]: this.getMonthCellData, + [QUARTER]: this.getQuarterCellData, + [YEAR]: this.getYearCellData, + [DECADE]: this.getDecadeData, + }; + + return ( + + {[DATE, WEEK].includes(mode) ? this.renderWeekdaysHead() : null} + {this.renderCellContent(mode2Data[mode]())} +
    + ); + } +} + +export default polyfill(DateTable); diff --git a/components/calendar2/panels/header-panel.jsx b/components/calendar2/panels/header-panel.jsx deleted file mode 100644 index 906f899b9a..0000000000 --- a/components/calendar2/panels/header-panel.jsx +++ /dev/null @@ -1,346 +0,0 @@ -import React from 'react'; -import { polyfill } from 'react-lifecycles-compat'; -import PT from 'prop-types'; -import { func, datejs } from '../../util'; - -import { CALENDAR_MODE, DATE_PANEL_MODE, CALENDAR_SHAPE } from '../constant'; -import Radio from '../../radio'; -import Select from '../../select'; -import Button from '../../button'; -import Icon from '../../icon'; - -const { renderNode } = func; -const { DATE, WEEK, QUARTER, MONTH, YEAR, DECADE } = DATE_PANEL_MODE; - -class HeaderPanel extends React.PureComponent { - static propTypes = { - rtl: PT.bool, - prefix: PT.string, - locale: PT.object, - mode: PT.any, - shape: PT.string, - value: PT.any, - panelMode: PT.any, - panelValue: PT.any, - validValue: PT.any, - showTitle: PT.bool, - showModeSwitch: PT.bool, - onModeChange: PT.func, - onPanelValueChange: PT.func, - onPanelModeChange: PT.func, - onPrev: PT.func, - onNext: PT.func, - onSuperPrev: PT.func, - onSuperNext: PT.func, - titleRender: PT.func, - /** - * 扩展操作区域渲染 - */ - renderHeaderExtra: PT.func, - /** - * 自定义头部渲染 - */ - headerRender: PT.func, - }; - - static defaultProps = { - showTitle: false, - }; - - constructor(props) { - super(props); - this.prefixCls = `${props.prefix}calendar2-header`; - } - - createPanelBtns = ({ unit, num = 1, isSuper = true }) => { - const value = this.props.panelValue.clone(); - const { prefixCls } = this; - const iconTypes = isSuper - ? ['arrow-double-left', 'arrow-double-right'] - : ['arrow-left', 'arrow-right']; - - return [ - , - , - ]; - }; - - handleClick(value, { unit, num, isSuper, isNext }) { - const { onPanelValueChange, onPrev, onSuperPrev, onNext, onSuperNext } = this.props; - - let handler; - - if (isSuper) { - handler = isNext ? onSuperNext : onSuperPrev; - } else { - handler = isNext ? onNext : onPrev; - } - - if (handler) { - handler(value, { unit, num }); - } else { - const m = isNext ? 'add' : 'subtract'; - onPanelValueChange(value[m](num, unit), `PANEL`); - } - } - - renderModeSwitch = () => { - const { mode, locale, onModeChange } = this.props; - - return ( - - {locale.month} - {locale.year} - - ); - }; - - renderMonthSelect = () => { - const { prefixCls } = this; - const { panelValue, onPanelValueChange } = this.props; - - const curMonth = panelValue.month(); - const dataSource = datejs.monthsShort().map((label, value) => { - return { - label, - value, - }; - }); - - return ( - onPanelValueChange(panelValue.year(v))} - /> - ); - } - - renderTextField() { - const { prefixCls } = this; - const { panelValue, locale, panelMode, onPanelModeChange } = this.props; - - const monthBeforeYear = locale.monthBeforeYear || false; - const localeData = datejs.localeData(); - - const year = panelValue.year() + (locale && locale.year === '年' ? '年' : ''); - const month = localeData.monthsShort()[panelValue.month()]; - const { DATE, WEEK, QUARTER, MONTH, YEAR, DECADE } = DATE_PANEL_MODE; - - let nodes; - const yearNode = ( - - ); - - switch (panelMode) { - case DATE: - case WEEK: { - const monthNode = ( - - ); - nodes = monthBeforeYear ? [monthNode, yearNode] : [yearNode, monthNode]; - break; - } - - case MONTH: - case QUARTER: { - nodes = yearNode; - break; - } - - case YEAR: { - const curYear = panelValue.year(); - const start = curYear - (curYear % 10); - const end = start + 9; - nodes = ( - - ); - break; - } - case DECADE: { - const curYear = panelValue.year(); - const start = curYear - (curYear % 100); - const end = start + 99; - - nodes = this.props.rtl ? `${end}-${start}` : `${start}-${end}`; - break; - } - } - - return ( -
    - {nodes} -
    - ); - } - - renderPanelHeader() { - const { createPanelBtns } = this; - const { panelMode } = this.props; - - let nodes = []; - - const textFieldNode = this.renderTextField(); - - switch (panelMode) { - case DATE: - case WEEK: { - const [preMonthBtn, nextMonthBtn] = createPanelBtns({ - unit: 'month', - isSuper: false, - }); - const [preYearBtn, nextYearBtn] = createPanelBtns({ - unit: 'year', - }); - - nodes = [preYearBtn, preMonthBtn, textFieldNode, nextMonthBtn, nextYearBtn]; - break; - } - case QUARTER: - case MONTH: { - const [preYearBtn, nextYearBtn] = createPanelBtns({ - unit: 'year', - }); - - nodes = [preYearBtn, textFieldNode, nextYearBtn]; - break; - } - case YEAR: { - const [preDecadeBtn, nextDecadeBtn] = createPanelBtns({ - unit: 'year', - num: 10, - }); - - nodes = [preDecadeBtn, textFieldNode, nextDecadeBtn]; - break; - } - case DECADE: { - const [preCenturyBtn, nextCenturyBtn] = createPanelBtns({ - unit: 'year', - num: 100, - }); - - nodes = [preCenturyBtn, textFieldNode, nextCenturyBtn]; - break; - } - } - - return nodes; - } - - renderInner() { - const { prefixCls } = this; - const { shape, showTitle, value, mode, showModeSwitch } = this.props; - - const nodes = []; - - if (shape === CALENDAR_SHAPE.PANEL) { - return this.renderPanelHeader(); - } else { - if (showTitle) { - nodes.push( -
    - {renderNode(this.props.titleRender, value.format(), [value])} -
    - ); - } - nodes.push( -
    - {this.renderYearSelect()} - {mode !== CALENDAR_MODE.YEAR ? this.renderMonthSelect() : null} - {showModeSwitch ? this.renderModeSwitch() : null} - {this.props.renderHeaderExtra && - this.props.renderHeaderExtra({ ...this.props })} -
    - ); - } - - return nodes; - } - - render() { - return ( -
    - {renderNode(this.props.headerRender, this.renderInner(), { ...this.props })} -
    - ); - } -} - -export default polyfill(HeaderPanel); diff --git a/components/calendar2/panels/header-panel.tsx b/components/calendar2/panels/header-panel.tsx new file mode 100644 index 0000000000..c241ea1d7a --- /dev/null +++ b/components/calendar2/panels/header-panel.tsx @@ -0,0 +1,368 @@ +import React, { type ReactElement } from 'react'; +import { polyfill } from 'react-lifecycles-compat'; +import PT from 'prop-types'; +import type { Dayjs, ManipulateType } from 'dayjs'; +import { func, datejs } from '../../util'; + +import { CALENDAR_MODE, DATE_PANEL_MODE, CALENDAR_SHAPE } from '../constant'; +import Radio from '../../radio'; +import Select from '../../select'; +import Button from '../../button'; +import Icon from '../../icon'; +import type { HeaderPanelProps } from '../types'; + +const { renderNode } = func; +const { DATE, WEEK, QUARTER, MONTH, YEAR, DECADE } = DATE_PANEL_MODE; + +class HeaderPanel extends React.PureComponent { + static propTypes = { + rtl: PT.bool, + prefix: PT.string, + locale: PT.object, + mode: PT.any, + shape: PT.string, + value: PT.any, + panelMode: PT.any, + panelValue: PT.any, + validValue: PT.any, + showTitle: PT.bool, + showModeSwitch: PT.bool, + onModeChange: PT.func, + onPanelValueChange: PT.func, + onPanelModeChange: PT.func, + onPrev: PT.func, + onNext: PT.func, + onSuperPrev: PT.func, + onSuperNext: PT.func, + titleRender: PT.func, + /** + * 扩展操作区域渲染 + */ + renderHeaderExtra: PT.func, + /** + * 自定义头部渲染 + */ + headerRender: PT.func, + }; + + static defaultProps = { + showTitle: false, + }; + prefixCls: string; + + constructor(props: HeaderPanelProps) { + super(props); + this.prefixCls = `${props.prefix}calendar2-header`; + } + + createPanelBtns = ({ + unit, + num = 1, + isSuper = true, + }: { + unit: ManipulateType; + num?: number; + isSuper?: boolean; + }) => { + const value = this.props.panelValue.clone(); + const { prefixCls } = this; + const iconTypes = isSuper + ? ['arrow-double-left', 'arrow-double-right'] + : ['arrow-left', 'arrow-right']; + + return [ + , + , + ]; + }; + + handleClick( + value: Dayjs, + { + unit, + num, + isSuper, + isNext, + }: { unit: ManipulateType; num: number; isSuper?: boolean; isNext?: boolean } + ) { + const { onPanelValueChange, onPrev, onSuperPrev, onNext, onSuperNext } = this.props; + + let handler; + + if (isSuper) { + handler = isNext ? onSuperNext : onSuperPrev; + } else { + handler = isNext ? onNext : onPrev; + } + + if (handler) { + handler(value, { unit, num }); + } else { + const m = isNext ? 'add' : 'subtract'; + onPanelValueChange(value[m](num, unit), `PANEL`); + } + } + + renderModeSwitch = () => { + const { mode, locale, onModeChange } = this.props; + + return ( + + {locale.month} + {locale.year} + + ); + }; + + renderMonthSelect = () => { + const { prefixCls } = this; + const { panelValue, onPanelValueChange } = this.props; + + const curMonth = panelValue.month(); + const dataSource = datejs.monthsShort().map((label, value) => { + return { + label, + value, + }; + }); + + return ( + onPanelValueChange(panelValue.year(v as number))} + /> + ); + } + + renderTextField() { + const { prefixCls } = this; + const { panelValue, locale, panelMode, onPanelModeChange } = this.props; + + const monthBeforeYear = locale.monthBeforeYear || false; + const localeData = datejs.localeData(); + + const year = panelValue.year() + (locale && locale.year === '年' ? '年' : ''); + const month = localeData.monthsShort()[panelValue.month()]; + const { DATE, WEEK, QUARTER, MONTH, YEAR, DECADE } = DATE_PANEL_MODE; + + let nodes; + const yearNode = ( + + ); + + switch (panelMode) { + case DATE: + case WEEK: { + const monthNode = ( + + ); + nodes = monthBeforeYear ? [monthNode, yearNode] : [yearNode, monthNode]; + break; + } + + case MONTH: + case QUARTER: { + nodes = yearNode; + break; + } + + case YEAR: { + const curYear = panelValue.year(); + const start = curYear - (curYear % 10); + const end = start + 9; + nodes = ( + + ); + break; + } + case DECADE: { + const curYear = panelValue.year(); + const start = curYear - (curYear % 100); + const end = start + 99; + + nodes = this.props.rtl ? `${end}-${start}` : `${start}-${end}`; + break; + } + } + + return ( +
    + {nodes} +
    + ); + } + + renderPanelHeader() { + const { createPanelBtns } = this; + const { panelMode } = this.props; + + let nodes: ReactElement[] = []; + + const textFieldNode = this.renderTextField(); + + switch (panelMode) { + case DATE: + case WEEK: { + const [preMonthBtn, nextMonthBtn] = createPanelBtns({ + unit: 'month', + isSuper: false, + }); + const [preYearBtn, nextYearBtn] = createPanelBtns({ + unit: 'year', + }); + + nodes = [preYearBtn, preMonthBtn, textFieldNode, nextMonthBtn, nextYearBtn]; + break; + } + case QUARTER: + case MONTH: { + const [preYearBtn, nextYearBtn] = createPanelBtns({ + unit: 'year', + }); + + nodes = [preYearBtn, textFieldNode, nextYearBtn]; + break; + } + case YEAR: { + const [preDecadeBtn, nextDecadeBtn] = createPanelBtns({ + unit: 'year', + num: 10, + }); + + nodes = [preDecadeBtn, textFieldNode, nextDecadeBtn]; + break; + } + case DECADE: { + const [preCenturyBtn, nextCenturyBtn] = createPanelBtns({ + unit: 'year', + num: 100, + }); + + nodes = [preCenturyBtn, textFieldNode, nextCenturyBtn]; + break; + } + } + + return nodes; + } + + renderInner() { + const { prefixCls } = this; + const { shape, showTitle, value, mode, showModeSwitch } = this.props; + + const nodes: ReactElement[] = []; + + if (shape === CALENDAR_SHAPE.PANEL) { + return this.renderPanelHeader(); + } else { + if (showTitle) { + nodes.push( +
    + {/* @ts-expect-error value 可能不是 Dayjs 形式 */} + {renderNode(this.props.titleRender, value!.format(), [value])} +
    + ); + } + nodes.push( +
    + {this.renderYearSelect()} + {mode !== CALENDAR_MODE.YEAR ? this.renderMonthSelect() : null} + {showModeSwitch ? this.renderModeSwitch() : null} + {this.props.renderHeaderExtra && + this.props.renderHeaderExtra({ ...this.props })} +
    + ); + } + + return nodes; + } + + render() { + return ( +
    + {renderNode(this.props.headerRender, this.renderInner(), { ...this.props })} +
    + ); + } +} + +export default polyfill(HeaderPanel); diff --git a/components/calendar2/prop-types.js b/components/calendar2/prop-types.js deleted file mode 100644 index 9673b932a8..0000000000 --- a/components/calendar2/prop-types.js +++ /dev/null @@ -1,24 +0,0 @@ -import PT from 'prop-types'; -import { CALENDAR_SHAPE, CALENDAR_MODE, DATE_PANEL_MODE } from './constant'; -import { datejs } from '../util'; - -const error = (propName, ComponentName) => - new Error(`Invalid prop ${propName} supplied to ${ComponentName}. Validation failed.`); - -const SharedPT = { - shape: PT.oneOf(Object.values(CALENDAR_SHAPE)), - mode: PT.oneOf(Object.values(CALENDAR_MODE)), - panelMode: PT.oneOf(Object.values(DATE_PANEL_MODE)), - // 日期类型: - // @string: 2020-11-11 - // @date: 日期对象 - // @moment: moment对象 - // @dayjs: dayjs对象 - date(props, propName, componentName) { - if (propName in props && !datejs(props.propName).isValid()) { - throw error(propName, componentName); - } - }, -}; - -export default SharedPT; diff --git a/components/calendar2/prop-types.ts b/components/calendar2/prop-types.ts new file mode 100644 index 0000000000..83a0f2299c --- /dev/null +++ b/components/calendar2/prop-types.ts @@ -0,0 +1,26 @@ +import PT from 'prop-types'; +import type { ConfigType } from 'dayjs'; +import { CALENDAR_SHAPE, CALENDAR_MODE, DATE_PANEL_MODE } from './constant'; +import { datejs } from '../util'; + +const error = (propName: string, ComponentName: string) => + new Error(`Invalid prop ${propName} supplied to ${ComponentName}. Validation failed.`); + +const SharedPT = { + shape: PT.oneOf(Object.values(CALENDAR_SHAPE)), + mode: PT.oneOf(Object.values(CALENDAR_MODE)), + panelMode: PT.oneOf(Object.values(DATE_PANEL_MODE)), + // 日期类型: + // @string: 2020-11-11 + // @date: 日期对象 + // @moment: moment 对象 + // @dayjs: dayjs 对象 + date(props: Record, propName: string, componentName: string) { + if (propName in props && !datejs(props[propName] as ConfigType).isValid()) { + return error(propName, componentName); + } + return null; + }, +}; + +export default SharedPT; diff --git a/components/calendar2/style.js b/components/calendar2/style.ts similarity index 100% rename from components/calendar2/style.js rename to components/calendar2/style.ts diff --git a/components/calendar2/types.ts b/components/calendar2/types.ts new file mode 100644 index 0000000000..ea9420a351 --- /dev/null +++ b/components/calendar2/types.ts @@ -0,0 +1,281 @@ +import type React from 'react'; +import type { Dayjs, ConfigType, ManipulateType } from 'dayjs'; +import type { CommonProps } from '../util'; +import type { Locale } from '../locale/types'; + +interface HTMLAttributesWeak + extends Omit, 'onChange' | 'defaultValue' | 'onSelect'> {} + +/** + * @api + */ +export interface CalendarProps extends HTMLAttributesWeak, Omit { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 默认选中的日期(dayjs 对象) + * @en Default selected date (dayjs object) + */ + defaultValue?: ConfigType; + + /** + * 选中的日期值 (dayjs 对象) + * @en Selected date value (dayjs object) + */ + value?: ConfigType; + + /** + * 面板默认显示的日期 + * @en Default displayed date + */ + defaultPanelValue?: ConfigType; + + /** + * 面板显示的日期(受控) + * @en Displayed date + */ + panelValue?: ConfigType; + + /** + * 展现形态 + * @en Display shape + * @defaultValue 'fullscreen' + */ + shape?: 'card' | 'fullscreen' | 'panel'; + + /** + * 日期模式 + * @en Date mode + * @defaultValue 'month' + */ + mode?: CalendarMode; + + /** + * 面板模式,未指定时会根据 mode 自动推断 + * @en Panel mode, will be inferred automatically if not specified + */ + panelMode?: CalendarPanelMode; + + /** + * 选择日期单元格时的回调 + * @en Callback when selecting a date cell + */ + onSelect?: (value: Dayjs, strVal: string) => void; + + /** + * 值改变时的回调 + * @en Callback when value changes + */ + onChange?: (value: Dayjs, strVal: string) => void; + + /** + * 日期面板变化回调 + * @en Callback when date panel changes + */ + onPanelChange?: (value: Dayjs, mode: string, reason?: string) => void; + + /** + * 自定义样式类 + * @en Custom style class + */ + className?: string; + + /** + * 自定义日期渲染 + * @en Custom date rendering + */ + dateCellRender?: CustomCellRender; + + /** + * 自定义月份渲染函数 + * @en Custom month rendering function + */ + monthCellRender?: CustomCellRender; + + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: CustomCellRender; + /** + * 自定义季度渲染函数 + * @en Custom quarter rendering function + */ + quarterCellRender?: CustomCellRender; + /** + * 不可选择的日期 + * @en Disabled date + */ + disabledDate?: (value: Dayjs, mode: CalendarPanelMode) => boolean; + /** + * @skip + */ + locale?: Locale['Calendar']; + /** + * 点击头部左单箭头时触发的回调 + * @en Callback when clicking the left single arrow + */ + onPrev?: OnPrevOrNext; + /** + * 点击头部右单箭头时触发的回调 + * @en Callback when clicking the right single arrow + */ + onNext?: OnPrevOrNext; + /** + * 点击头部左双箭头时触发的回调 + * @en callback when clicking the left double arrow + */ + onSuperPrev?: OnPrevOrNext; + /** + * 点击头部右双箭头时触发的回调 + * @en callback when clicking the right double arrow + */ + onSuperNext?: OnPrevOrNext; + /** + * 头部自定义渲染 + * @en Header custom rendering + */ + headerRender?: (props: HeaderPanelProps) => React.ReactNode; + /** + * 可选择的年份的有效区间 + * @en Valid year range + */ + validValue?: [Dayjs, Dayjs]; + /** + * 渲染头部额外内容 + * @en Render header extra content + */ + renderHeaderExtra?: (props: HeaderPanelProps) => React.ReactNode; + /** + * 渲染头部标题,仅当 showTitle 为 true 时生效 + * @en Render header title + * @skip + */ + titleRender?: (value: ConfigType) => React.ReactNode; + /** + * 显示头部标题 + * @en Show header title + * @skip + * @remarks 实现有问题暂不开放 + */ + showTitle?: boolean; + /** + * 展示的总行数 + * @en Total rows + * @skip + * @remarks 命名有些问题,感觉应该叫 rowNum + */ + colNum?: number; + /** + * @skip + * @deprecated not implemented + */ + hoveredState?: (value: unknown) => boolean; + /** + * 单元格自定义样式 + * @en Cell custom style + */ + cellClassName?: (value: Dayjs) => Record | undefined | null; + /** + * 单元格自定义属性 + * @en Cell custom property + */ + cellProps?: { + onMouseEnter?: ( + v: Dayjs, + e: React.MouseEvent, + args: Pick + ) => void; + onMouseLeave?: ( + v: Dayjs, + e: React.MouseEvent, + args: Pick + ) => void; + }; +} + +/** + * @api + */ +export type OnPrevOrNext = (value: Dayjs, options: { unit: ManipulateType; num: number }) => void; + +/** + * @api + */ +export type CalendarMode = 'month' | 'year'; + +/** + * @api + */ +export type CalendarPanelMode = 'date' | 'week' | 'month' | 'quarter' | 'year' | 'decade'; + +export interface CalendarState { + value: CalendarProps['value']; + mode: CalendarMode; + panelMode: CalendarPanelMode; + panelValue: Dayjs; +} + +export interface HeaderPanelProps extends Omit { + panelValue: Dayjs; + locale: Locale['Calendar']; + onPanelValueChange: (value: Dayjs, type?: 'PANEL') => void; + onPrev?: OnPrevOrNext; + onNext?: OnPrevOrNext; + onSuperPrev?: OnPrevOrNext; + onSuperNext?: OnPrevOrNext; + mode: CalendarMode; + onModeChange: (mode: CalendarMode) => void; + validValue?: CalendarProps['validValue']; + panelMode: CalendarPanelMode; + onPanelModeChange: (mode: CalendarPanelMode) => void; + shape: CalendarProps['shape']; + showTitle: boolean; + value?: CalendarState['value']; + showModeSwitch: boolean; + renderHeaderExtra?: CalendarProps['renderHeaderExtra']; + headerRender?: CalendarProps['headerRender']; + titleRender?: CalendarProps['titleRender']; +} + +/** + * @api + */ +export type CustomCellRender = (value: Dayjs) => React.ReactNode; + +export interface DateTableProps extends Omit { + mode: CalendarPanelMode; + panelValue: Dayjs; + colNum?: number; + hoveredState?: CalendarProps['hoveredState']; + cellClassName?: CalendarProps['cellClassName']; + dateCellRender?: CustomCellRender; + quarterCellRender?: CustomCellRender; + monthCellRender?: CustomCellRender; + yearCellRender?: CustomCellRender; + cellProps?: CalendarProps['cellProps']; + disabledDate?: CalendarProps['disabledDate']; + onSelect?: ( + v: Dayjs, + e: React.MouseEvent | React.KeyboardEvent, + args: Pick + ) => void; + value: ConfigType; +} + +export interface DateTableState { + hoverValue: unknown; +} + +/** + * @api + */ +export interface CellData { + value: Dayjs; + label: number | string; + isCurrent: boolean; + key: string | number; +} diff --git a/components/card/__docs__/adaptor/index.jsx b/components/card/__docs__/adaptor/index.jsx deleted file mode 100644 index d6fc1a7db4..0000000000 --- a/components/card/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import { Card, Button } from '@alifd/next'; -import { Types } from '@alifd/adaptor-helper'; - -export default { - name: 'Card', - editor: () => ({ - props: [{ - name: 'divider', - type: Types.bool, - default: true, - }, { - name: 'width', - type: Types.number, - default: 300, - }, { - name: 'height', - label: 'height', - type: Types.number, - default: 215, - }, { - name: 'title', - type: Types.string, - default: 'Title' - }, { - name: 'subTitle', - label: 'Subtitle', - type: Types.string, - default: '', - }, { - name: 'extra', - label: 'Extra Data', - type: Types.string, - default: '', - }], - data: { - default: '', - } - }), - adaptor: ({ bullet, divider, expand, width, height, title, subTitle, extra, style, data, ...others }) => { - const cardStyle = { - width: width === 0 ? '' : width, - height: height === 0 ? 'auto' : height, - ...style, - }; - - return ( - - {extra}} /> - {divider && } - - {data} - - - ); - }, - content: () => ({ - options: [{ - name: 'bullet', - options: ['show', 'hide'], - default: 'hide' - }, { - name: 'divider', - options: ['show', 'hide'], - default: 'show' - }, { - name: 'expanded', - options: ['yes', 'no'], - default: 'no' - }, { - name: 'subTitle', - options: ['show', 'hide'], - default: 'hide' - }, { - name: 'link', - options: ['show', 'hide'], - default: 'hide' - }], - transform: (props, { bullet, divider, expanded, subTitle, link }) => { - return { - ...props, - bullet: bullet === 'show', - divider: divider === 'show', - expand: expanded === 'yes', - subTitle: subTitle === 'show' ? 'Sub Title' : '', - extra: link === 'show' ? 'Link' : '', - }; - } - }) -}; diff --git a/components/card/__docs__/adaptor/index.tsx b/components/card/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..2f2d7fdfb7 --- /dev/null +++ b/components/card/__docs__/adaptor/index.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Card, Button } from '@alifd/next'; +import { Types } from '@alifd/adaptor-helper'; + +export default { + name: 'Card', + editor: () => ({ + props: [ + { + name: 'divider', + type: Types.bool, + default: true, + }, + { + name: 'width', + type: Types.number, + default: 300, + }, + { + name: 'height', + label: 'height', + type: Types.number, + default: 215, + }, + { + name: 'title', + type: Types.string, + default: 'Title', + }, + { + name: 'subTitle', + label: 'Subtitle', + type: Types.string, + default: '', + }, + { + name: 'extra', + label: 'Extra Data', + type: Types.string, + default: '', + }, + ], + data: { + default: '', + }, + }), + adaptor: ({ + bullet, + divider, + expand, + width, + height, + title, + subTitle, + extra, + style, + data, + ...others + }: any) => { + const cardStyle = { + width: width === 0 ? '' : width, + height: height === 0 ? 'auto' : height, + ...style, + }; + + return ( + + + {extra} + + } + /> + {divider && } + {data} + + ); + }, + content: () => ({ + options: [ + { + name: 'bullet', + options: ['show', 'hide'], + default: 'hide', + }, + { + name: 'divider', + options: ['show', 'hide'], + default: 'show', + }, + { + name: 'expanded', + options: ['yes', 'no'], + default: 'no', + }, + { + name: 'subTitle', + options: ['show', 'hide'], + default: 'hide', + }, + { + name: 'link', + options: ['show', 'hide'], + default: 'hide', + }, + ], + transform: (props: any, { bullet, divider, expanded, subTitle, link }: any) => { + return { + ...props, + bullet: bullet === 'show', + divider: divider === 'show', + expand: expanded === 'yes', + subTitle: subTitle === 'show' ? 'Sub Title' : '', + extra: link === 'show' ? 'Link' : '', + }; + }, + }), +}; diff --git a/components/card/__docs__/demo/divider/index.tsx b/components/card/__docs__/demo/divider/index.tsx index f7ff0037cf..e258fa64cf 100644 --- a/components/card/__docs__/demo/divider/index.tsx +++ b/components/card/__docs__/demo/divider/index.tsx @@ -3,7 +3,7 @@ import ReactDOM from 'react-dom'; import { Card, Button, Box } from '@alifd/next'; const commonProps = { - title: 'Title', + title: 'Simple Card', style: { width: 300 }, subTitle: 'Sub-title', extra: ( @@ -16,7 +16,7 @@ const commonProps = { ReactDOM.render( - + Lorem ipsum dolor sit amet, est viderer iuvaret perfecto et. Ne petentium quaerendum @@ -34,7 +34,7 @@ ReactDOM.render( - + Lorem ipsum dolor sit amet, est viderer iuvaret perfecto et. Ne petentium quaerendum diff --git a/components/card/__docs__/index.en-us.md b/components/card/__docs__/index.en-us.md index c864f54cf3..9dc72bd659 100644 --- a/components/card/__docs__/index.en-us.md +++ b/components/card/__docs__/index.en-us.md @@ -19,50 +19,51 @@ A card could contain a photo, text, and a link about a single subject. ### Card -| Param | Description | Type | Default Value | -| --------------- | ------------ | ------------- | ---- | -| title | Title of card | String | - | -| subTitle | Sub title of card | String | - | -| showTitleBullet | If show title bullet | Boolean | true | -| showHeadDivider | If show head divider | Boolean | true | -| contentHeight | Height of content | String/Number | 120 | -| extra | Extra of card header | ReactNode | - | -| media | Media content | ReactNode | - | -| actions | Actions of card | ReactNode | - | -| free | Whether to open free mode, if opened, can`t set title subTitle ..., must use Card.Header Card.Content ... to set Card | Boolean | - | +| Param | Description | Type | Default Value | Required | Supported Version | +| --------------- | ----------------------------------------------------------------------------------------------------------------------- | ---------------- | ------------- | -------- | ----------------- | +| media | Media content | ReactNode | - | | - | +| title | Title of card | ReactNode | - | | - | +| subTitle | Sub title of card | ReactNode | - | | - | +| actions | Actions of card | ReactNode | - | | - | +| showTitleBullet | If show title bullet | boolean | true | | - | +| showHeadDivider | If show head divider | boolean | true | | - | +| contentHeight | Height of content | string \| number | 120 | | - | +| extra | Extra of card header | ReactNode | - | | - | +| free | Whether to open free mode, if opened, can not set title subTitle ..., must use Card.Header Card.Content ... to set Card | boolean | false | | - | +| hasBorder | Whether to show border | boolean | true | | 1.24 | -### Card.Actions +### Card.Media -| Param | Description | Type | Default Value | -| --------- | ------ | ------ | ----- | -| component | The html tag to be rendered | custom | 'div' | +| Param | Description | Type | Default Value | Required | +| --------- | --------------------------- | ----------- | ------------- | -------- | +| component | The html tag to be rendered | ElementType | 'div' | | +| image | Media background image | string | - | | +| src | Media source URL | string | - | | -### Card.Content +### Card.Header -| Param | Description | Type | Default Value | -| --------- | ------ | ------ | ----- | -| component | The html tag to be rendered | custom | 'div' | +| Param | Description | Type | Default Value | Required | +| --------- | --------------------------- | ----------- | ------------- | -------- | +| title | Title of card | ReactNode | - | | +| subTitle | Sub Title of Card | ReactNode | - | | +| extra | Extra of card header | ReactNode | - | | +| component | The html tag to be rendered | ElementType | 'div' | | -### Card.Divider +### Card.Content -| Param | Description | Type | Default Value | -| --------- | ------ | ------ | ---- | -| component | The html tag to be rendered | custom | 'hr' | -| inset | inset | Boolean | - | +| Param | Description | Type | Default Value | Required | +| --------- | --------------------------- | ----------- | ------------- | -------- | +| component | The html tag to be rendered | ElementType | 'div' | | -### Card.Header +### Card.Divider -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------------ | --------- | ----- | -| title | Title of card | ReactNode | - | -| subTitle | Sub Title of Card | ReactNode | - | -| extra | Extra of card header | ReactNode | - | -| component | The html tag to be rendered | custom | 'div' | +| Param | Description | Type | Default Value | Required | +| --------- | --------------------------- | ----------- | ------------- | -------- | +| component | The html tag to be rendered | ElementType | 'hr' | | +| inset | Inset | boolean | - | | -### Card.Media +### Card.Actions -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------- | ------ | ----- | -| component | The html tag to be rendered | custom | 'div' | -| image | Media background image | String | - | -| src | Media source URL | String | - | +| Param | Description | Type | Default Value | Required | +| --------- | --------------------------- | ----------- | ------------- | -------- | +| component | The html tag to be rendered | ElementType | 'div' | | diff --git a/components/card/__docs__/index.md b/components/card/__docs__/index.md index 9267411c2f..d10d8de793 100644 --- a/components/card/__docs__/index.md +++ b/components/card/__docs__/index.md @@ -17,51 +17,51 @@ ### Card -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| --------------- | ------------------------------------------------------------ | ------------- | ----- | ---- | -| media | 卡片的上的图片 / 视频 | ReactNode | - | | -| title | 卡片的标题 | ReactNode | - | | -| subTitle | 卡片的副标题 | ReactNode | - | | -| actions | 卡片操作组,位置在卡片底部 | ReactNode | - | | -| showTitleBullet | 是否显示标题的项目符号 | Boolean | true | | -| showHeadDivider | 是否展示头部的分隔线 | Boolean | true | | -| contentHeight | 内容区域的固定高度 | String/Number | 120 | | -| extra | 标题区域的用户自定义内容 | ReactNode | - | | -| free | 是否开启自由模式,开启后card 将使用子组件配合使用, 设置此项后 title, subtitle, 等等属性都将失效 | Boolean | false | | -| hasBorder | 是否带边框 | Boolean | true | 1.24 | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| --------------- | ------------------------------------------------------------------------------------------------ | ---------------- | ------ | -------- | -------- | +| media | 卡片的上的图片 / 视频 | ReactNode | - | | - | +| title | 卡片的标题 | ReactNode | - | | - | +| subTitle | 卡片的副标题 | ReactNode | - | | - | +| actions | 卡片操作组,位置在卡片底部 | ReactNode | - | | - | +| showTitleBullet | 是否显示标题的项目符号 | boolean | true | | - | +| showHeadDivider | 是否展示头部的分隔线 | boolean | true | | - | +| contentHeight | 内容区域的固定高度 | string \| number | 120 | | - | +| extra | 标题区域的用户自定义内容 | ReactNode | - | | - | +| free | 是否开启自由模式,开启后 card 将使用子组件配合使用,设置此项后 title, subtitle, 等等属性都将失效 | boolean | false | | - | +| hasBorder | 是否带边框 | boolean | true | | 1.24 | ### Card.Media -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------- | ------ | ----- | -| component | 设置标签类型 | custom | 'div' | -| image | 背景图片地址 | String | - | -| src | 媒体源文件地址 | String | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | -------------- | ----------- | ------ | -------- | +| component | 设置标签类型 | ElementType | 'div' | | +| image | 背景图片地址 | string | - | | +| src | 媒体源文件地址 | string | - | | ### Card.Header -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------------ | --------- | ----- | -| title | 卡片的标题 | ReactNode | - | -| subTitle | 卡片的副标题 | ReactNode | - | -| extra | 标题区域的用户自定义内容 | ReactNode | - | -| component | 设置标签类型 | custom | 'div' | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | ------------------------ | ----------- | ------ | -------- | +| title | 卡片的标题 | ReactNode | - | | +| subTitle | 卡片的副标题 | ReactNode | - | | +| extra | 标题区域的用户自定义内容 | ReactNode | - | | +| component | 设置标签类型 | ElementType | 'div' | | ### Card.Content -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------ | ------ | ----- | -| component | 设置标签类型 | custom | 'div' | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | ------------ | ----------- | ------ | -------- | +| component | 设置标签类型 | ElementType | 'div' | | ### Card.Divider -| 参数 | 说明 | 类型 | 默认值 | -| --------- | --------- | ------- | ---- | -| component | 设置标签类型 | custom | 'hr' | -| inset | 分割线是否向内缩进 | Boolean | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | ------------------ | ----------- | ------ | -------- | +| component | 设置标签类型 | ElementType | 'hr' | | +| inset | 分割线是否向内缩进 | boolean | - | | ### Card.Actions -| 参数 | 说明 | 类型 | 默认值 | -| --------- | ------ | ------ | ----- | -| component | 设置标签类型 | custom | 'div' | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------- | ------------ | ----------- | ------ | -------- | +| component | 设置标签类型 | ElementType | 'div' | | diff --git a/components/card/__docs__/theme/index.jsx b/components/card/__docs__/theme/index.jsx deleted file mode 100644 index 7b248e223b..0000000000 --- a/components/card/__docs__/theme/index.jsx +++ /dev/null @@ -1,145 +0,0 @@ -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import ConfigProvider from '../../../config-provider'; -import Card from '../../index'; -import Button from '../../../button'; -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; -import '../../../demo-helper/style'; -import '../../style'; - -/*eslint-disable*/ -const i18nMap = { - 'zh-cn': { - card: '卡片', - normal: '基本', - title: '标题', - subTitle: '副标题', - link: '链接', - noUnderline: '无标题分隔线', - bullet: '带标题标识', - }, - 'en-us': { - card: 'Card', - normal: 'Normal', - title: 'Title', - subTile: 'Description', - link: 'Link', - noUnderline: 'No Header Line', - bullet: 'Bullet', - } -}; - -const cardStyle = { - width: 360, -}; - -const placeholderStyle = { - textAlign: 'left', - fontSize: '14px', - color: '#666' -}; - -const extendPlaceholderStyle = { - height: '120px', - textAlign: 'center' -}; - -function CardDemo({ locale, divider, noSubtitle, noLink, demoFunction, onFunctionChange }) { - const commonProps = { - subTitle: noSubtitle ? '' : locale.subTile, - extra: noLink ? '' : , - }; - - return ( - - - - - {divider && } - - Lorem ipsum dolor sit amet, est viderer iuvaret perfecto et. Ne petentium quaerendum nec, eos ex recteque mediocritatem, ex usu assum legendos temporibus. Ius feugiat pertinacia an, cu verterem praesent quo. - - {divider && } - - - - - - - - ) -} - -class FunctionDemo extends React.Component { - constructor(props) { - super(props); - this.state = { - demoFunction: { - divider: { - label: '分割线', - value: 'false', - enum: [ - { label: '显示', value: 'true' }, - { label: '隐藏', value: 'false' }, - { label: '内嵌', value: 'inset' } - ], - }, - showSubTitle: { - label: '副标题', - value: 'false', - enum: [ - { label: '显示', value: 'true' }, - { label: '隐藏', value: 'false' } - ], - }, - showLink: { - label: '有无链接', - value: 'false', - enum: [ - { label: '显示', value: 'true' }, - { label: '隐藏', value: 'false' } - ], - }, - }, - }; - } - - onFunctionChange = (ret) => { - this.setState({ - demoFunction: ret, - }); - } - - render() { - const { title, locale } = this.props; - const { demoFunction } = this.state; - - const divider = demoFunction.divider.value === 'false' ? '' : demoFunction.divider.value; - const noSubtitle = demoFunction.showSubTitle.value === 'false'; - const noLink = demoFunction.showLink.value === 'false'; - const cardDemoProps = { - locale, - divider, - noSubtitle, - noLink, - demoFunction, - onFunctionChange: this.onFunctionChange, - }; - - return (); - } -} - -function render(i18n, lang) { - return ReactDOM.render(
    - -
    , document.getElementById('container')); -} - -window.renderDemo = function(lang = 'en-us') { - render(i18nMap[lang], lang); -}; - -renderDemo(); - -initDemo('card'); diff --git a/components/card/__docs__/theme/index.tsx b/components/card/__docs__/theme/index.tsx new file mode 100644 index 0000000000..8db6d6764f --- /dev/null +++ b/components/card/__docs__/theme/index.tsx @@ -0,0 +1,187 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Demo, DemoGroup, initDemo, type DemoFunctionDefineForObject } from '../../../demo-helper'; +import ConfigProvider from '../../../config-provider'; +import Card from '../../index'; +import Button from '../../../button'; +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; +import '../../../demo-helper/style'; +import '../../style'; + +/*eslint-disable*/ +const i18nMap = { + 'zh-cn': { + card: '卡片', + normal: '基本', + title: '标题', + subTitle: '副标题', + link: '链接', + noUnderline: '无标题分隔线', + bullet: '带标题标识', + }, + 'en-us': { + card: 'Card', + normal: 'Normal', + title: 'Title', + subTile: 'Description', + link: 'Link', + noUnderline: 'No Header Line', + bullet: 'Bullet', + }, +}; + +const cardStyle = { + width: 360, +}; + +const placeholderStyle = { + textAlign: 'left', + fontSize: '14px', + color: '#666', +}; + +const extendPlaceholderStyle = { + height: '120px', + textAlign: 'center', +}; +interface RenderCardState { + demoFunction: Record; +} + +interface RenderCardProps { + i18n?: Record; + locale: Record; + title?: string; +} +interface RenderCardDemoProps { + locale: Record; + divider: string | unknown; + noSubtitle: boolean; + noLink: boolean; + demoFunction: Record; + onFunctionChange: (ret: Record) => void; +} +function CardDemo({ + locale, + divider, + noSubtitle, + noLink, + demoFunction, + onFunctionChange, +}: RenderCardDemoProps) { + const commonProps = { + subTitle: noSubtitle ? '' : locale.subTile, + extra: noLink ? ( + '' + ) : ( + + ), + }; + + return ( + + + + + {divider && } + + Lorem ipsum dolor sit amet, est viderer iuvaret perfecto et. Ne petentium + quaerendum nec, eos ex recteque mediocritatem, ex usu assum legendos + temporibus. Ius feugiat pertinacia an, cu verterem praesent quo. + + {divider && } + + + + + + + + ); +} + +class FunctionDemo extends React.Component { + constructor(props: RenderCardProps) { + super(props); + this.state = { + demoFunction: { + divider: { + label: '分割线', + value: 'false', + enum: [ + { label: '显示', value: 'true' }, + { label: '隐藏', value: 'false' }, + { label: '内嵌', value: 'inset' }, + ], + }, + showSubTitle: { + label: '副标题', + value: 'false', + enum: [ + { label: '显示', value: 'true' }, + { label: '隐藏', value: 'false' }, + ], + }, + showLink: { + label: '有无链接', + value: 'false', + enum: [ + { label: '显示', value: 'true' }, + { label: '隐藏', value: 'false' }, + ], + }, + }, + }; + } + + onFunctionChange = (ret: Record) => { + this.setState({ + demoFunction: ret, + }); + }; + + render() { + const { locale } = this.props; + const { demoFunction } = this.state; + + const divider = demoFunction.divider.value === 'false' ? '' : demoFunction.divider.value; + const noSubtitle = demoFunction.showSubTitle.value === 'false'; + const noLink = demoFunction.showLink.value === 'false'; + const cardDemoProps = { + locale, + divider, + noSubtitle, + noLink, + demoFunction, + onFunctionChange: this.onFunctionChange, + }; + + return ; + } +} + +function render(i18n: Record, lang: string) { + return ReactDOM.render( + +
    + +
    +
    , + document.getElementById('container') + ); +} + +window.renderDemo = function (lang = 'en-us') { + render(i18nMap[lang], lang); +}; + +renderDemo(); + +initDemo('card'); diff --git a/components/card/__tests__/a11y-spec.js b/components/card/__tests__/a11y-spec.js deleted file mode 100644 index 8f135c362f..0000000000 --- a/components/card/__tests__/a11y-spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Card from '../index'; -import '../style'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Card A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations when default', async () => { - wrapper = await testReact( - -
    - - ); - return wrapper; - }); - - it('should not have any violations when displaying images', async () => { - wrapper = await testReact( - - father day -
    -

    Father's Day

    -

    Thank you, papa

    -
    -
    - ); - return wrapper; - }); - - it('should not have any violations when setting height', async () => { - const commonProps = { - style: { width: 300 }, - title: 'Title', - subTitle: 'Sub-title', - }; - - wrapper = await testReact( -
    - -
    -

    Card content

    -

    Card content

    -
    -
    -    - -
    -

    Card content

    -

    Card content

    -
    -
    -
    - ); - return wrapper; - }); - - it('should not have any violations when setting title off', async () => { - const commonProps = { - style: { width: 300 }, - title: 'Title', - }; - - wrapper = await testReact( - - Card Content - - ); - return wrapper; - }); -}); diff --git a/components/card/__tests__/a11y-spec.tsx b/components/card/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..f3f00d0e7a --- /dev/null +++ b/components/card/__tests__/a11y-spec.tsx @@ -0,0 +1,68 @@ +import React from 'react'; +import Card from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Card A11y', () => { + it('should not have any violations when default', async () => { + await testReact( + +
    + + ); + }); + + it('should not have any violations when displaying images', async () => { + await testReact( + + father day +
    +

    Father's Day

    +

    Thank you, papa

    +
    +
    + ); + }); + + it('should not have any violations when setting height', async () => { + const commonProps = { + style: { width: 300 }, + title: 'Title', + subTitle: 'Sub-title', + }; + + await testReact( +
    + +
    +

    Card content

    +

    Card content

    +
    +
    +    + +
    +

    Card content

    +

    Card content

    +
    +
    +
    + ); + }); + + it('should not have any violations when setting title off', async () => { + const commonProps = { + style: { width: 300 }, + title: 'Title', + }; + + await testReact( + + Card Content + + ); + }); +}); diff --git a/components/card/__tests__/index-spec.js b/components/card/__tests__/index-spec.js deleted file mode 100644 index f7b05bbbfa..0000000000 --- a/components/card/__tests__/index-spec.js +++ /dev/null @@ -1,181 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Button from '../../button'; -import Card from '../index'; -import '../style'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable */ -describe('Card', () => { - const commonProps = { - style: { width: 300 }, - title: 'Title', - subTitle: 'sub title', - extra: 'Link', - }; - - describe('render', () => { - let wrapper; - - afterEach(() => { - wrapper = null; - }); - - it('should render card', () => { - wrapper = mount(Card content); - assert(wrapper.find('.next-card').length === 1); - assert(wrapper.find('.next-card-head-show-bullet').length === 1); - assert(wrapper.find('.next-card-head-show-underline').length === 0); - }); - - it('should render without title bullet', () => { - wrapper = mount( - - Card Content - - ); - assert(wrapper.find('.next-card-head-show-bullet').length === 0); - }); - - it('should render without head underline', () => { - wrapper = mount( - - Card Content - - ); - assert(wrapper.find('.next-card-head-show-underline').length === 0); - }); - - it('should render without head', () => { - wrapper = mount( - - Card Content - - ); - assert(wrapper.find('.next-card-head').length === 0); - }); - - it('should render media & actions', () => { - wrapper = mount( - } - actions={ - - } - > - Card Content - - ); - - assert(wrapper.find('.next-card-media').length > 0); - assert(wrapper.find('.next-card-actions').length > 0); - }); - - it('should render when contentHeight is auto', () => { - wrapper = mount( - - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    - Card Content
    -
    - ); - - assert(wrapper.find('.next-card-content').instance().style.height === '300px'); - - wrapper.setProps({ contentHeight: 'auto' }); - assert(wrapper.find('.next-card-content').instance().style.height === 'auto'); - }); - - it('should render free', () => { - wrapper = mount( - - - - - - Link - - } - /> - - Card Content - - - - - - ); - - assert(wrapper.find('.next-card-free').length > 0); - // assert(wrapper.find('.next-card-actions').length > 0); - }); - }); - - describe('action', () => { - let wrapper, parent; - - beforeEach(() => { - parent = document.createElement('div'); - parent.setAttribute('id', 'react-app'); - document.body.appendChild(parent); - }); - - afterEach(() => { - document.body.removeChild(parent); - parent = null; - wrapper = null; - }); - - it('should expand card', done => { - wrapper = mount( - -
    - , - { attachTo: parent } - ); - assert(wrapper.find('.next-icon-arrow-down.expand').length === 0); - wrapper.find(Button).simulate('click'); - assert(wrapper.find('.next-icon-arrow-down.expand').length === 1); - done(); - }); - }); -}); diff --git a/components/card/__tests__/index-spec.tsx b/components/card/__tests__/index-spec.tsx new file mode 100644 index 0000000000..c845f5c373 --- /dev/null +++ b/components/card/__tests__/index-spec.tsx @@ -0,0 +1,152 @@ +import React from 'react'; +import Button from '../../button'; +import Card from '../index'; +import '../style'; + +describe('Card', () => { + const commonProps = { + style: { width: 300 }, + title: 'Title', + subTitle: 'sub title', + extra: 'Link', + }; + + describe('render', () => { + it('should render card', () => { + cy.mount(Card content); + cy.get('.next-card').should('have.length', 1); + cy.get('.next-card-head-show-bullet').should('have.length', 1); + cy.get('.next-card-head-show-underline').should('not.exist'); + }); + + it('should render without title bullet', () => { + cy.mount( + + Card Content + + ); + cy.get('.next-card-head-show-bullet').should('not.exist'); + }); + + it('should render without head underline', () => { + cy.mount( + + Card Content + + ); + cy.get('.next-card-head-show-underline').should('not.exist'); + }); + + it('should render without head', () => { + cy.mount( + + Card Content + + ); + cy.get('.next-card-head').should('not.exist'); + }); + + it('should render media & actions', () => { + cy.mount( + + } + actions={ + + } + > + Card Content + + ); + cy.get('.next-card-media').should('exist'); + cy.get('.next-card-actions').should('exist'); + }); + + it('should render when contentHeight is auto', () => { + cy.mount( + + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    + Card Content
    +
    + ).as('Demo'); + + cy.get('.next-card-content').should('have.css', 'height', '300px'); + + cy.rerender('Demo', { + contentHeight: 'auto', + }); + cy.get('.next-card-content').should($element => { + const height = parseInt($element.css('height'), 10); + expect(height).to.be.greaterThan(400); + }); + }); + + it('should render free', () => { + cy.mount( + + + + + + Link + + } + /> + + Card Content + + + + + + ); + cy.get('.next-card-free').should('exist'); + }); + }); + + describe('action', () => { + it('should expand card', () => { + cy.mount( + +
    + + ); + cy.get('.next-icon-arrow-down').should('not.have.class', 'expand'); + cy.get('Button').click(); + cy.get('.next-icon-arrow-down').should('have.class', 'expand'); + }); + }); +}); diff --git a/components/card/actions.jsx b/components/card/actions.jsx deleted file mode 100644 index 73a5bffc61..0000000000 --- a/components/card/actions.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Card.Actions - * @order 5 - */ -class CardActions extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 设置标签类型 - */ - component: PropTypes.elementType, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - component: 'div', - }; - - render() { - const { prefix, component: Component, className, ...others } = this.props; - return ; - } -} - -export default ConfigProvider.config(CardActions); diff --git a/components/card/actions.tsx b/components/card/actions.tsx new file mode 100644 index 0000000000..8a7fa2fcf5 --- /dev/null +++ b/components/card/actions.tsx @@ -0,0 +1,27 @@ +import React, { Component, type ElementType } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { CardActionsProps } from './types'; + +class CardActions extends Component { + static displayName = 'CardActions'; + static propTypes = { + prefix: PropTypes.string, + component: PropTypes.elementType, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + component: 'div', + }; + + render() { + const { prefix, component, className, ...others } = this.props; + const Component = component as ElementType; + return ; + } +} + +export default ConfigProvider.config(CardActions); diff --git a/components/card/bullet-header.jsx b/components/card/bullet-header.jsx deleted file mode 100644 index cea094a50f..0000000000 --- a/components/card/bullet-header.jsx +++ /dev/null @@ -1,64 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Card.BulletHeader - * @order 2 - */ -class CardBulletHeader extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 卡片的标题 - */ - title: PropTypes.node, - /** - * 卡片的副标题 - */ - subTitle: PropTypes.node, - /** - * 是否显示标题的项目符号 - */ - showTitleBullet: PropTypes.bool, - /** - * 标题区域的用户自定义内容 - */ - extra: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - showTitleBullet: true, - }; - - render() { - const { prefix, title, subTitle, extra, showTitleBullet } = this.props; - - if (!title) return null; - - const headCls = classNames({ - [`${prefix}card-head`]: true, - [`${prefix}card-head-show-bullet`]: showTitleBullet, - }); - - const headExtra = extra ?
    {extra}
    : null; - - return ( -
    -
    -
    - {title} - {subTitle ? {subTitle} : null} -
    - {headExtra} -
    -
    - ); - } -} - -export default ConfigProvider.config(CardBulletHeader, { - componentName: 'Card', -}); diff --git a/components/card/bullet-header.tsx b/components/card/bullet-header.tsx new file mode 100644 index 0000000000..c57a3a9399 --- /dev/null +++ b/components/card/bullet-header.tsx @@ -0,0 +1,52 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { CardBulletHeaderProps } from './types'; + +class CardBulletHeader extends Component { + static displayName = 'CardBulletHeader'; + static propTypes = { + prefix: PropTypes.string, + title: PropTypes.node, + subTitle: PropTypes.node, + showTitleBullet: PropTypes.bool, + extra: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + showTitleBullet: true, + }; + + render() { + const { prefix, title, subTitle, extra, showTitleBullet } = this.props; + + if (!title) return null; + + const headCls = classNames({ + [`${prefix}card-head`]: true, + [`${prefix}card-head-show-bullet`]: showTitleBullet, + }); + + const headExtra = extra ?
    {extra}
    : null; + + return ( +
    +
    +
    + {title} + {subTitle ? ( + {subTitle} + ) : null} +
    + {headExtra} +
    +
    + ); + } +} + +export default ConfigProvider.config(CardBulletHeader, { + componentName: 'Card', +}); diff --git a/components/card/card.jsx b/components/card/card.jsx deleted file mode 100644 index 19768c3e3a..0000000000 --- a/components/card/card.jsx +++ /dev/null @@ -1,121 +0,0 @@ -/* eslint-disable valid-jsdoc */ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; -import BulletHeader from './bullet-header'; -import CollapseContent from './collapse-content'; -import CardMedia from './media'; -import CardActions from './actions'; -import { obj } from '../util'; - -const { pickOthers } = obj; - -/** - * Card - * @order 0 - */ -export default class Card extends React.Component { - static displayName = 'Card'; - - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 卡片的上的图片 / 视频 - */ - media: PropTypes.node, - /** - * 卡片的标题 - */ - title: PropTypes.node, - /** - * 卡片的副标题 - */ - subTitle: PropTypes.node, - /** - * 卡片操作组,位置在卡片底部 - */ - actions: PropTypes.node, - /** - * 是否显示标题的项目符号 - */ - showTitleBullet: PropTypes.bool, - /** - * 是否展示头部的分隔线 - */ - showHeadDivider: PropTypes.bool, - /** - * 内容区域的固定高度 - */ - contentHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** - * 标题区域的用户自定义内容 - */ - extra: PropTypes.node, - /** - * 是否开启自由模式,开启后card 将使用子组件配合使用, 设置此项后 title, subtitle, 等等属性都将失效 - */ - free: PropTypes.bool, - /** - * 是否带边框 - * @version 1.24 - */ - hasBorder: PropTypes.bool, - className: PropTypes.string, - children: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - free: false, - showTitleBullet: true, - showHeadDivider: true, - hasBorder: true, - contentHeight: 120, - }; - - render() { - const { - prefix, - className, - title, - subTitle, - extra, - showTitleBullet, - showHeadDivider, - children, - rtl, - contentHeight, - free, - actions, - hasBorder, - media, - } = this.props; - - const cardCls = classNames( - { - [`${prefix}card`]: true, - [`${prefix}card-free`]: free, - [`${prefix}card-noborder`]: !hasBorder, - [`${prefix}card-show-divider`]: showHeadDivider, - [`${prefix}card-hide-divider`]: !showHeadDivider, - }, - className - ); - - const others = pickOthers(Object.keys(Card.propTypes), this.props); - - others.dir = rtl ? 'rtl' : undefined; - - return ( -
    - {media && {media}} - - {free ? children : {children}} - {actions && {actions}} -
    - ); - } -} diff --git a/components/card/card.tsx b/components/card/card.tsx new file mode 100644 index 0000000000..98cbe5b6b2 --- /dev/null +++ b/components/card/card.tsx @@ -0,0 +1,96 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; + +import ConfigProvider from '../config-provider'; +import BulletHeader from './bullet-header'; +import CollapseContent from './collapse-content'; +import CardMedia from './media'; +import CardActions from './actions'; +import { obj } from '../util'; +import type { CardProps } from './types'; + +const { pickOthers } = obj; + +export default class Card extends Component { + static displayName = 'Card'; + + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + media: PropTypes.node, + title: PropTypes.node, + subTitle: PropTypes.node, + actions: PropTypes.node, + showTitleBullet: PropTypes.bool, + showHeadDivider: PropTypes.bool, + contentHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + extra: PropTypes.node, + free: PropTypes.bool, + hasBorder: PropTypes.bool, + className: PropTypes.string, + children: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + free: false, + showTitleBullet: true, + showHeadDivider: true, + hasBorder: true, + contentHeight: 120, + }; + + render() { + const { + prefix, + className, + title, + subTitle, + extra, + showTitleBullet, + showHeadDivider, + children, + rtl, + contentHeight, + free, + actions, + hasBorder, + media, + } = this.props; + + const cardCls = classNames( + { + [`${prefix}card`]: true, + [`${prefix}card-free`]: free, + [`${prefix}card-noborder`]: !hasBorder, + [`${prefix}card-show-divider`]: showHeadDivider, + [`${prefix}card-hide-divider`]: !showHeadDivider, + }, + className + ); + + const others = pickOthers(Card.propTypes, this.props); + + others.dir = rtl ? 'rtl' : undefined; + + return ( +
    + {media && {media}} + + {free ? ( + children + ) : ( + {children} + )} + {actions && {actions}} +
    + ); + } +} diff --git a/components/card/collapse-content.jsx b/components/card/collapse-content.jsx deleted file mode 100644 index 4446677f93..0000000000 --- a/components/card/collapse-content.jsx +++ /dev/null @@ -1,137 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import Icon from '../icon'; -import Button from '../button'; -import ConfigProvider from '../config-provider'; -import nextLocale from '../locale/zh-cn'; - -/** - * Card.CollapseContent - * @order 3 - */ -class CardCollapseContent extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 内容区域的固定高度 - */ - contentHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - locale: PropTypes.object, - children: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - contentHeight: 120, - locale: nextLocale.Card, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - needMore: false, - expand: false, - contentHeight: 'auto', - }; - } - - componentDidMount() { - this._setNeedMore(); - this._setContentHeight(); - } - - componentDidUpdate() { - this._setContentHeight(); - } - - handleToggle = () => { - this.setState(prevState => { - return { - expand: !prevState.expand, - }; - }); - }; - - // 是否展示 More 按钮 - _setNeedMore() { - const { contentHeight } = this.props; - const childrenHeight = this._getNodeChildrenHeight(this.content); - this.setState({ - needMore: contentHeight !== 'auto' && childrenHeight > contentHeight, - }); - } - - // 设置 Body 的高度 - _setContentHeight() { - if (this.props.contentHeight === 'auto') { - this.content.style.height = 'auto'; - return; - } - - if (this.state.expand) { - const childrenHeight = this._getNodeChildrenHeight(this.content); - this.content.style.height = `${childrenHeight}px`; // get the real height - } else { - const el = ReactDOM.findDOMNode(this.footer); - let height = this.props.contentHeight; - - if (el) { - height = height - el.getBoundingClientRect().height; - } - - this.content.style.height = `${height}px`; - } - } - - _getNodeChildrenHeight(node) { - if (!node) { - return 0; - } - - const contentChildNodes = node.childNodes; - const length = contentChildNodes.length; - - if (!length) { - return 0; - } - - const lastNode = contentChildNodes[length - 1]; - - return lastNode.offsetTop + lastNode.offsetHeight; - } - - _contentRefHandler = ref => { - this.content = ref; - }; - - saveFooter = ref => { - this.footer = ref; - }; - - render() { - const { prefix, children, locale } = this.props; - const { needMore, expand } = this.state; - - return ( -
    -
    - {children} -
    - {needMore ? ( -
    - -
    - ) : null} -
    - ); - } -} - -export default ConfigProvider.config(CardCollapseContent, { - componentName: 'Card', -}); diff --git a/components/card/collapse-content.tsx b/components/card/collapse-content.tsx new file mode 100644 index 0000000000..812478f051 --- /dev/null +++ b/components/card/collapse-content.tsx @@ -0,0 +1,147 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import PropTypes from 'prop-types'; +import Icon from '../icon'; +import Button from '../button'; +import ConfigProvider from '../config-provider'; +import nextLocale from '../locale/zh-cn'; +import type { CardCollapseContentProps } from './types'; + +export interface CardCollapseContentState { + needMore: boolean; + expand: boolean; + contentHeight: string | number; +} + +class CardCollapseContent extends Component { + static displayName = 'CardCollapseContent'; + static propTypes = { + prefix: PropTypes.string, + /** + * 内容区域的固定高度 + */ + contentHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + locale: PropTypes.object, + children: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + contentHeight: 120, + locale: nextLocale.Card, + }; + content: HTMLDivElement; + footer: HTMLDivElement; + + constructor(props: CardCollapseContentProps) { + super(props); + + this.state = { + needMore: false, + expand: false, + contentHeight: 'auto', + }; + } + + componentDidMount() { + this._setNeedMore(); + this._setContentHeight(); + } + + componentDidUpdate() { + this._setContentHeight(); + } + + handleToggle = () => { + this.setState(prevState => { + return { + expand: !prevState.expand, + }; + }); + }; + + // 是否展示 More 按钮 + _setNeedMore() { + const { contentHeight } = this.props; + const childrenHeight = this._getNodeChildrenHeight(this.content); + this.setState({ + needMore: contentHeight !== 'auto' && childrenHeight > (contentHeight as number), + }); + } + + // 设置 Body 的高度 + _setContentHeight() { + if (this.props.contentHeight === 'auto') { + this.content.style.height = 'auto'; + return; + } + + if (this.state.expand) { + const childrenHeight = this._getNodeChildrenHeight(this.content); + this.content.style.height = `${childrenHeight}px`; // get the real height + } else { + const el = ReactDOM.findDOMNode(this.footer) as Element | null; + let height = this.props.contentHeight; + + if (el) { + height = (height as number) - el.getBoundingClientRect().height; + } + + this.content.style.height = `${height}px`; + } + } + + _getNodeChildrenHeight(node?: HTMLDivElement) { + if (!node) { + return 0; + } + + const contentChildNodes = node.childNodes; + const length = contentChildNodes.length; + + if (!length) { + return 0; + } + + const lastNode = contentChildNodes[length - 1] as HTMLElement; + + return lastNode.offsetTop + lastNode.offsetHeight; + } + + _contentRefHandler = (ref: HTMLDivElement) => { + this.content = ref; + }; + + saveFooter = (ref: HTMLDivElement) => { + this.footer = ref; + }; + + render() { + const { prefix, children, locale } = this.props; + const { needMore, expand } = this.state; + + return ( +
    +
    + {children} +
    + {needMore ? ( +
    + +
    + ) : null} +
    + ); + } +} + +export default ConfigProvider.config(CardCollapseContent, { + componentName: 'Card', +}); diff --git a/components/card/content.jsx b/components/card/content.jsx deleted file mode 100644 index 7a368d9c5a..0000000000 --- a/components/card/content.jsx +++ /dev/null @@ -1,31 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Card.Content - * @order 3 - */ -class CardContent extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 设置标签类型 - */ - component: PropTypes.elementType, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - component: 'div', - }; - - render() { - const { prefix, className, component: Component, ...others } = this.props; - return ; - } -} - -export default ConfigProvider.config(CardContent); diff --git a/components/card/content.tsx b/components/card/content.tsx new file mode 100644 index 0000000000..b2955d7415 --- /dev/null +++ b/components/card/content.tsx @@ -0,0 +1,35 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { CardContentProps } from './types'; + +class CardContent extends Component { + static displayName = 'CardContent'; + static propTypes = { + prefix: PropTypes.string, + /** + * 设置标签类型 + */ + component: PropTypes.elementType, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + component: 'div', + }; + + render() { + const { prefix, className, component, ...others } = this.props; + const Component = component as React.ElementType; + return ( + + ); + } +} + +export default ConfigProvider.config(CardContent); diff --git a/components/card/divider.jsx b/components/card/divider.jsx deleted file mode 100644 index 0db14ab144..0000000000 --- a/components/card/divider.jsx +++ /dev/null @@ -1,44 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Card.Divider - * @order 4 - */ -class CardDivider extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 设置标签类型 - */ - component: PropTypes.elementType, - /** - * 分割线是否向内缩进 - */ - inset: PropTypes.bool, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - component: 'hr', - }; - - render() { - const { prefix, component: Component, inset, className, ...others } = this.props; - - const dividerClassName = classNames( - `${prefix}card-divider`, - { - [`${prefix}card-divider--inset`]: inset, - }, - className - ); - - return ; - } -} - -export default ConfigProvider.config(CardDivider); diff --git a/components/card/divider.tsx b/components/card/divider.tsx new file mode 100644 index 0000000000..79b8e009cb --- /dev/null +++ b/components/card/divider.tsx @@ -0,0 +1,42 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { CardDividerProps } from './types'; + +class CardDivider extends Component { + static displayName = 'CardDivider'; + static propTypes = { + prefix: PropTypes.string, + /** + * 设置标签类型 + */ + component: PropTypes.elementType, + /** + * 分割线是否向内缩进 + */ + inset: PropTypes.bool, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + component: 'hr', + }; + + render() { + const { prefix, component, inset, className, ...others } = this.props; + const Component = component as React.ElementType; + const dividerClassName = classNames( + `${prefix}card-divider`, + { + [`${prefix}card-divider--inset`]: inset, + }, + className + ); + + return ; + } +} + +export default ConfigProvider.config(CardDivider); diff --git a/components/card/header.jsx b/components/card/header.jsx deleted file mode 100644 index 5049240496..0000000000 --- a/components/card/header.jsx +++ /dev/null @@ -1,52 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; - -/** - * Card.Header - * @order 2 - */ -class CardHeader extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 卡片的标题 - */ - title: PropTypes.node, - /** - * 卡片的副标题 - */ - subTitle: PropTypes.node, - /** - * 标题区域的用户自定义内容 - */ - extra: PropTypes.node, - /** - * 设置标签类型 - */ - component: PropTypes.elementType, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - component: 'div', - }; - - render() { - const { prefix, title, subTitle, extra, className, component: Component, ...others } = this.props; - - return ( - - {extra &&
    {extra}
    } -
    - {title &&
    {title}
    } - {subTitle &&
    {subTitle}
    } -
    -
    - ); - } -} - -export default ConfigProvider.config(CardHeader); diff --git a/components/card/header.tsx b/components/card/header.tsx new file mode 100644 index 0000000000..b6a2879a87 --- /dev/null +++ b/components/card/header.tsx @@ -0,0 +1,50 @@ +import React, { Component, type ElementType } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import type { CardHeaderProps } from './types'; + +class CardHeader extends Component { + static displayName = 'CardHeader'; + static propTypes = { + prefix: PropTypes.string, + /** + * 卡片的标题 + */ + title: PropTypes.node, + /** + * 卡片的副标题 + */ + subTitle: PropTypes.node, + /** + * 标题区域的用户自定义内容 + */ + extra: PropTypes.node, + /** + * 设置标签类型 + */ + component: PropTypes.elementType, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + component: 'div', + }; + + render() { + const { prefix, title, subTitle, extra, className, component, ...others } = this.props; + const Component = component as ElementType; + return ( + + {extra &&
    {extra}
    } +
    + {title &&
    {title}
    } + {subTitle &&
    {subTitle}
    } +
    +
    + ); + } +} + +export default ConfigProvider.config(CardHeader); diff --git a/components/card/index.d.ts b/components/card/index.d.ts deleted file mode 100644 index 79ab9ce5fa..0000000000 --- a/components/card/index.d.ts +++ /dev/null @@ -1,147 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - title?: any; -} - -export interface CardProps extends HTMLAttributesWeak, CommonProps { - /** - * 卡片的上的图片 / 视频 - */ - media?: React.ReactNode; - - /** - * 卡片的标题 - */ - title?: React.ReactNode; - - /** - * 卡片的副标题 - */ - subTitle?: React.ReactNode; - - /** - * 卡片操作组,位置在卡片底部 - */ - actions?: React.ReactNode; - - /** - * 是否显示标题的项目符号 - */ - showTitleBullet?: boolean; - - /** - * 是否展示头部的分隔线 - */ - showHeadDivider?: boolean; - - /** - * 内容区域的固定高度 - */ - contentHeight?: string | number; - - /** - * 标题区域的用户自定义内容 - */ - extra?: React.ReactNode; - - /** - * 是否开启自由模式,开启后card 将使用子组件配合使用, 设置此项后 title, subtitle, 等等属性都将失效 - */ - free?: boolean; - hasBorder?: boolean; -} - -export interface CardBulletHeaderProps extends HTMLAttributesWeak, CommonProps { - /** - * 卡片的标题 - */ - title?: React.ReactNode; - - /** - * 卡片的副标题 - */ - subTitle?: React.ReactNode; - /** - * 是否显示标题的项目符号 - */ - showTitleBullet?: boolean; - /** - * 标题区域的用户自定义内容 - */ - extra?: React.ReactNode; -} - -export interface CardCollaspeContentProps extends HTMLAttributesWeak, CommonProps { - contentHeight?: string | number; -} -export interface CardCollapseContentProps extends HTMLAttributesWeak, CommonProps { - contentHeight?: string | number; -} - -export interface CardHeaderProps extends HTMLAttributesWeak, CommonProps { - /** - * 卡片的标题 - */ - title?: React.ReactNode; - - /** - * 卡片的副标题 - */ - subTitle?: React.ReactNode; - - /** - * 标题区域的用户自定义内容 - */ - extra?: React.ReactNode; - - /** - * 设置标签类型 - */ - component?: React.ElementType; -} - -export interface CardContentProps extends HTMLAttributesWeak, CommonProps { - /** - * 设置标签类型 - */ - component?: React.ElementType; -} - -export interface CardMediaProps extends HTMLAttributesWeak, CommonProps { - /** - * 设置标签类型 - */ - component?: React.ElementType; - /** - * 背景图片地址 - */ - image?: string; - /** - * 媒体源文件地址 - */ - src?: string; -} - -export interface CardActionsProps extends HTMLAttributesWeak, CommonProps {} - -export interface CardDividerProps extends HTMLAttributesWeak, CommonProps { - /** - * 分割线是否向内缩进 - */ - inset?: boolean; -} - -export default class Card extends React.Component { - static BulletHeader: React.ComponentType; - static CollaspeContent: React.ComponentType; - static CollapseContent: React.ComponentType; - static Header: React.ComponentType; - static Content: React.ComponentType; - static Media: React.ComponentType; - static Actions: React.ComponentType; - static Divider: React.ComponentType; -} diff --git a/components/card/index.jsx b/components/card/index.jsx deleted file mode 100644 index 00eb5c5a9f..0000000000 --- a/components/card/index.jsx +++ /dev/null @@ -1,40 +0,0 @@ -import ConfigProvider from '../config-provider'; -import Card from './card'; -import CardHeader from './header'; -import CardBulletHeader from './bullet-header'; -import CardMedia from './media'; -import CardDivider from './divider'; -import CardContent from './content'; -import CollaspeContent from './collapse-content'; -import CardActions from './actions'; - -Card.Header = CardHeader; -Card.Media = CardMedia; -Card.Divider = CardDivider; -Card.Content = CardContent; -Card.Actions = CardActions; -Card.BulletHeader = CardBulletHeader; -Card.CollaspeContent = CollaspeContent; -Card.CollapseContent = CollaspeContent; - -export default ConfigProvider.config(Card, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('titlePrefixLine' in props) { - deprecated('titlePrefixLine', 'showTitleBullet', 'Card'); - const { titlePrefixLine, ...others } = props; - props = { showTitleBullet: titlePrefixLine, ...others }; - } - if ('titleBottomLine' in props) { - deprecated('titleBottomLine', 'showHeadDivider', 'Card'); - const { titleBottomLine, ...others } = props; - props = { showHeadDivider: titleBottomLine, ...others }; - } - if ('bodyHeight' in props) { - deprecated('bodyHeight', 'contentHeight', 'Card'); - const { bodyHeight, ...others } = props; - props = { contentHeight: bodyHeight, ...others }; - } - - return props; - }, -}); diff --git a/components/card/index.tsx b/components/card/index.tsx new file mode 100644 index 0000000000..eab2c612bf --- /dev/null +++ b/components/card/index.tsx @@ -0,0 +1,59 @@ +import ConfigProvider from '../config-provider'; +import { assignSubComponent } from '../util/component'; +import Card from './card'; +import CardHeader from './header'; +import CardBulletHeader from './bullet-header'; +import CardMedia from './media'; +import CardDivider from './divider'; +import CardContent from './content'; +import CollapseContent from './collapse-content'; +import CardActions from './actions'; + +export type { + CardProps, + CardMediaProps, + CardHeaderProps, + CardContentProps, + CardDividerProps, + CardActionsProps, + CardBulletHeaderProps, + CardCollaspeContentProps, + CardCollapseContentProps, +} from './types'; + +const WithSubCard = assignSubComponent(Card, { + Header: CardHeader, + Media: CardMedia, + Divider: CardDivider, + Content: CardContent, + Actions: CardActions, + BulletHeader: CardBulletHeader, + /** + * typo of CollapseContent + * @deprecated Use CollapseContent instead + */ + CollaspeContent: CollapseContent, + CollapseContent: CollapseContent, +}); + +export default ConfigProvider.config(WithSubCard, { + transform: (props, deprecated) => { + if ('titlePrefixLine' in props) { + deprecated('titlePrefixLine', 'showTitleBullet', 'Card'); + const { titlePrefixLine, ...others } = props; + props = { showTitleBullet: titlePrefixLine as boolean, ...others }; + } + if ('titleBottomLine' in props) { + deprecated('titleBottomLine', 'showHeadDivider', 'Card'); + const { titleBottomLine, ...others } = props; + props = { showHeadDivider: titleBottomLine as boolean, ...others }; + } + if ('bodyHeight' in props) { + deprecated('bodyHeight', 'contentHeight', 'Card'); + const { bodyHeight, ...others } = props; + props = { contentHeight: bodyHeight as number | string, ...others }; + } + + return props; + }, +}); diff --git a/components/card/media.jsx b/components/card/media.jsx deleted file mode 100644 index 8a14bea2a0..0000000000 --- a/components/card/media.jsx +++ /dev/null @@ -1,61 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; -import { log } from '../util'; - -const { warning } = log; - -const MEDIA_COMPONENTS = ['video', 'audio', 'picture', 'iframe', 'img']; - -/** - * Card.Media - * @order 1 - */ -class CardMedia extends Component { - static propTypes = { - prefix: PropTypes.string, - /** - * 设置标签类型 - */ - component: PropTypes.elementType, - /** - * 背景图片地址 - */ - image: PropTypes.string, - /** - * 媒体源文件地址 - */ - src: PropTypes.string, - style: PropTypes.object, - className: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - component: 'div', - style: {}, - }; - - render() { - const { prefix, style, className, component: Component, image, src, ...others } = this.props; - - if (!('children' in others || Boolean(image || src))) { - warning('either `children`, `image` or `src` prop must be specified.'); - } - - const isMediaComponent = MEDIA_COMPONENTS.indexOf(Component) !== -1; - const composedStyle = !isMediaComponent && image ? { backgroundImage: `url("${image}")`, ...style } : style; - - return ( - - ); - } -} - -export default ConfigProvider.config(CardMedia); diff --git a/components/card/media.tsx b/components/card/media.tsx new file mode 100644 index 0000000000..cd72450cef --- /dev/null +++ b/components/card/media.tsx @@ -0,0 +1,60 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import { log } from '../util'; +import type { CardMediaProps } from './types'; + +const { warning } = log; + +const MEDIA_COMPONENTS = ['video', 'audio', 'picture', 'iframe', 'img']; + +class CardMedia extends Component { + static displayName = 'CardMedia'; + static propTypes = { + prefix: PropTypes.string, + /** + * 设置标签类型 + */ + component: PropTypes.elementType, + /** + * 背景图片地址 + */ + image: PropTypes.string, + /** + * 媒体源文件地址 + */ + src: PropTypes.string, + style: PropTypes.object, + className: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + component: 'div', + style: {}, + }; + + render() { + const { prefix, style, className, component, image, src, ...others } = this.props; + const Component = component as React.ElementType; + if (!('children' in others || Boolean(image || src))) { + warning('either `children`, `image` or `src` prop must be specified.'); + } + + const isMediaComponent = MEDIA_COMPONENTS.indexOf(component as string) !== -1; + const composedStyle = + !isMediaComponent && image ? { backgroundImage: `url("${image}")`, ...style } : style; + + return ( + + ); + } +} + +export default ConfigProvider.config(CardMedia); diff --git a/components/card/mobile/index.jsx b/components/card/mobile/index.jsx deleted file mode 100644 index 8d5295d3c7..0000000000 --- a/components/card/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Card as MeetCard } from '@alifd/meet-react'; -import NextCard from '../index'; - -const Card = MeetCard ? MeetCard : NextCard; - -export default Card; diff --git a/components/card/mobile/index.tsx b/components/card/mobile/index.tsx new file mode 100644 index 0000000000..c54bcf7226 --- /dev/null +++ b/components/card/mobile/index.tsx @@ -0,0 +1,7 @@ +import { Card as MeetCard } from '@alifd/meet-react'; +import NextCard from '../index'; + +// @ts-expect-error meet-react does not export Card +const Card = MeetCard ? MeetCard : NextCard; + +export default Card; diff --git a/components/card/style.js b/components/card/style.ts similarity index 100% rename from components/card/style.js rename to components/card/style.ts diff --git a/components/card/types.ts b/components/card/types.ts new file mode 100644 index 0000000000..5bc4f0a15b --- /dev/null +++ b/components/card/types.ts @@ -0,0 +1,298 @@ +import type { ElementType, HTMLAttributes, ReactNode } from 'react'; +import type { CommonProps } from '../util'; + +type HTMLAttributesWeak = Omit, 'title'>; + +/** + * @api Card + * @order 0 + */ +export interface CardProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + + /** + * 卡片的上的图片 / 视频 + * @en Media content + */ + media?: ReactNode; + + /** + * 卡片的标题 + * @en Title of card + */ + title?: ReactNode; + + /** + * 卡片的副标题 + * @en Sub title of card + */ + subTitle?: ReactNode; + + /** + * 卡片操作组,位置在卡片底部 + * @en Actions of card + */ + actions?: ReactNode; + + /** + * 是否显示标题的项目符号 + * @en If show title bullet + * @defaultValue true + */ + showTitleBullet?: boolean; + + /** + * 是否显示标题的项目符号 + * @en If show title bullet + * @deprecated Use showTitleBullet + * @skip + */ + titlePrefixLine?: boolean; + + /** + * 是否展示头部的分隔线 + * @en If show head divider + * @defaultValue true + */ + showHeadDivider?: boolean; + + /** + * 是否展示头部的分隔线 + * @en If show head divider + * @deprecated Use showHeadDivider + * @skip + */ + titleBottomLine?: boolean; + + /** + * 内容区域的固定高度 + * @en Height of content + * @defaultValue 120 + */ + contentHeight?: string | number; + + /** + * 内容区域的固定高度 + * @en Height of content + * @deprecated Use contentHeight + * @skip + */ + bodyHeight?: string | number; + + /** + * 标题区域的用户自定义内容 + * @en Extra of card header + */ + extra?: ReactNode; + + /** + * 是否开启自由模式,开启后 card 将使用子组件配合使用,设置此项后 title, subtitle, 等等属性都将失效 + * @en Whether to open free mode, if opened, can not set title subTitle ..., must use Card.Header Card.Content ... to set Card + * @defaultValue false + */ + free?: boolean; + + /** + * 是否带边框 + * @en Whether to show border + * @defaultValue true + * @version 1.24 + */ + hasBorder?: boolean; +} + +/** + * @api Card.Media + * @order 1 + */ +export interface CardMediaProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 设置样式 + * @en The style of component + * @defaultValue \{\} + * @skip + */ + style?: React.CSSProperties; + /** + * 设置标签类型 + * @en The html tag to be rendered + * @defaultValue 'div' + */ + component?: ElementType; + /** + * 背景图片地址 + * @en Media background image + */ + image?: string; + /** + * 媒体源文件地址 + * @en Media source URL + */ + src?: string; +} + +/** + * @api Card.Header + * @order 2 + */ +export interface CardHeaderProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 卡片的标题 + * @en Title of card + */ + title?: ReactNode; + + /** + * 卡片的副标题 + * @en Sub Title of Card + */ + subTitle?: ReactNode; + + /** + * 标题区域的用户自定义内容 + * @en Extra of card header + */ + extra?: ReactNode; + + /** + * 设置标签类型 + * @en The html tag to be rendered + * @defaultValue 'div' + */ + component?: ElementType; +} + +/** + * @api Card.Content + * @order 3 + */ +export interface CardContentProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 设置标签类型 + * @en The html tag to be rendered + * @defaultValue 'div' + */ + component?: ElementType; +} + +/** + * @api Card.Divider + * @order 4 + */ +export interface CardDividerProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 设置标签类型 + * @en The html tag to be rendered + * @defaultValue 'hr' + */ + component?: ElementType; + + /** + * 分割线是否向内缩进 + * @en inset + */ + inset?: boolean; +} + +/** + * @api Card.Actions + * @order 5 + */ +export interface CardActionsProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 设置标签类型 + * @en The html tag to be rendered + * @defaultValue 'div' + */ + component?: ElementType; +} + +export interface CardBulletHeaderProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 卡片的标题 + */ + title?: ReactNode; + + /** + * 卡片的副标题 + */ + subTitle?: ReactNode; + /** + * 是否显示标题的项目符号 + */ + showTitleBullet?: boolean; + /** + * 标题区域的用户自定义内容 + */ + extra?: ReactNode; +} + +export interface CardCollapseContentProps extends HTMLAttributesWeak, CommonProps { + /** + * 设置类名前缀 + * @en The prefix of class + * @defaultValue 'next-' + * @skip + */ + prefix?: string; + /** + * 设置内容区域的固定高度 + * @en Height of content + * @defaultValue 120 + */ + contentHeight?: string | number; +} + +/** + * typo of CardCollapseContentProps + * @deprecated use CardCollapseContentProps instead + */ +export type CardCollaspeContentProps = CardCollapseContentProps; diff --git a/components/cascader-select/__docs__/adaptor/index.jsx b/components/cascader-select/__docs__/adaptor/index.jsx deleted file mode 100644 index 5008b9103f..0000000000 --- a/components/cascader-select/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,106 +0,0 @@ -import React from 'react'; -import { Types, parseData, NodeType } from '@alifd/adaptor-helper'; -import { CascaderSelect } from '@alifd/next'; - -let index = 1000; -const createDataSource = (list, map = {}) => { - if (!list) return []; - return list.filter((item) => item.type === NodeType.node).map(({ value, children, state }) => { - const key = String(index++); - if (state === 'active') { - if (!children || children.length === 0) { - map.selecteds.push(key); - } else { - map.expandeds.push(key); - } - } - - return { - value: key, - label: value, - disabled: state === 'disabled', - children: createDataSource(children, map), - }; - }); -}; -export default { - name: 'CascaderSelect', - editor: () => ({ - props: [{ - name: 'size', - type: Types.enum, - options: ['large', 'medium', 'small'], - default: 'medium' - }, { - name: 'state', - label: 'Status', - type: Types.enum, - options: ['normal', 'expanded', 'disabled'], - default: 'normal' - }, { - name: 'width', - type: Types.number, - default: 300, - }, { - name: 'border', - type: Types.bool, - default: true, - }, { - name: 'checkbox', - type: Types.bool, - default: false - }, { - name: 'label', - type: Types.string, - default: '' - }], - data: { - active: true, - disabled: true, - icon: true, - default: '*1\n\t*1-1\n\t\t1-1-1\n\t\t1-1-2\n\t\t1-1-3\n\t\t1-1-4\n\t\t*1-1-5\n\t1-2\n\t1-3\n\t1-4\n\t1-5\n2\n\t2-1\n\t2-2\n\t2-3\n\t2-4\n\t2-5\n3\n\t3-1\n\t3-2\n\t3-3\n\t3-4\n\t3-5\n4\n\t4-1\n\t4-2\n\t4-3\n\t4-4\n\t4-5\n5\n\t5-1\n\t5-2\n\t5-3\n\t5-4\n\t5-5' - } - }), - adaptor: ({ shape, size, state, width, border, checkbox, label, data, style = {}, ...others}) => { - const list = parseData(data); - const map = { selecteds: [], expandeds: [] }; - const dataSource = createDataSource(list, map); - const value = map.selecteds; - - return ( - node} hasBorder={border} size={size} multiple={checkbox} value={value} visible={state === 'expanded'} disabled={state=== 'disabled'} dataSource={dataSource}/> - ); - }, - content: () => ({ - options: [{ - name: 'checkbox', - options: ['yes', 'no'], - default: 'no' - }, { - name: 'border', - options: ['show', 'hide'], - default: 'show' - }, { - name: 'label', - options: ['yes', 'no'], - default: 'no' - }], - transform: (props, { checkbox, border, label }) => { - return { - ...props, - checkbox: checkbox === 'yes', - border: border === 'show', - label: label === 'yes' ? 'Label' : '' - }; - } - }), - demoOptions: (demo) => { - const { node } = demo; - const { props = {} } = node; - if (props.state === 'expanded') { - return { ...demo, height: 300 }; - } - - return demo; - } -}; diff --git a/components/cascader-select/__docs__/adaptor/index.tsx b/components/cascader-select/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..d7179405b8 --- /dev/null +++ b/components/cascader-select/__docs__/adaptor/index.tsx @@ -0,0 +1,149 @@ +import React from 'react'; +import { Types, parseData, NodeType } from '@alifd/adaptor-helper'; +import { CascaderSelect } from '@alifd/next'; + +let index = 1000; +const createDataSource = ( + list: Array, + map: { selecteds: string[]; expandeds: string[] } +): Array => { + if (!list) return []; + return list + .filter(item => item.type === NodeType.node) + .map(({ value, children, state }) => { + const key = String(index++); + if (state === 'active') { + if (!children || children.length === 0) { + map.selecteds.push(key); + } else { + map.expandeds.push(key); + } + } + + return { + value: key, + label: value, + disabled: state === 'disabled', + children: createDataSource(children, map), + }; + }); +}; +export default { + name: 'CascaderSelect', + editor: () => ({ + props: [ + { + name: 'size', + type: Types.enum, + options: ['large', 'medium', 'small'], + default: 'medium', + }, + { + name: 'state', + label: 'Status', + type: Types.enum, + options: ['normal', 'expanded', 'disabled'], + default: 'normal', + }, + { + name: 'width', + type: Types.number, + default: 300, + }, + { + name: 'border', + type: Types.bool, + default: true, + }, + { + name: 'checkbox', + type: Types.bool, + default: false, + }, + { + name: 'label', + type: Types.string, + default: '', + }, + ], + data: { + active: true, + disabled: true, + icon: true, + default: + '*1\n\t*1-1\n\t\t1-1-1\n\t\t1-1-2\n\t\t1-1-3\n\t\t1-1-4\n\t\t*1-1-5\n\t1-2\n\t1-3\n\t1-4\n\t1-5\n2\n\t2-1\n\t2-2\n\t2-3\n\t2-4\n\t2-5\n3\n\t3-1\n\t3-2\n\t3-3\n\t3-4\n\t3-5\n4\n\t4-1\n\t4-2\n\t4-3\n\t4-4\n\t4-5\n5\n\t5-1\n\t5-2\n\t5-3\n\t5-4\n\t5-5', + }, + }), + adaptor: ({ + shape, + size, + state, + width, + border, + checkbox, + label, + data, + style = {}, + ...others + }: any) => { + const list = parseData(data); + const map = { selecteds: [], expandeds: [] }; + const dataSource = createDataSource(list, map); + const value = map.selecteds; + + return ( + node} + hasBorder={border} + size={size} + multiple={checkbox} + value={value} + visible={state === 'expanded'} + disabled={state === 'disabled'} + dataSource={dataSource} + /> + ); + }, + content: () => ({ + options: [ + { + name: 'checkbox', + options: ['yes', 'no'], + default: 'no', + }, + { + name: 'border', + options: ['show', 'hide'], + default: 'show', + }, + { + name: 'label', + options: ['yes', 'no'], + default: 'no', + }, + ], + transform: (props: any, { checkbox, border, label }: any) => { + return { + ...props, + checkbox: checkbox === 'yes', + border: border === 'show', + label: label === 'yes' ? 'Label' : '', + }; + }, + }), + demoOptions: (demo: any) => { + const { node } = demo; + const { props = {} } = node; + if (props.state === 'expanded') { + return { ...demo, height: 300 }; + } + + return demo; + }, +}; diff --git a/components/cascader-select/__docs__/demo/accessibility/index.tsx b/components/cascader-select/__docs__/demo/accessibility/index.tsx index baa567b440..38ccffb696 100644 --- a/components/cascader-select/__docs__/demo/accessibility/index.tsx +++ b/components/cascader-select/__docs__/demo/accessibility/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; import 'whatwg-fetch'; const data = [ @@ -48,19 +49,15 @@ const data = [ ]; class Demo extends React.Component { - constructor(props) { - super(props); - this.state = { - data: [], - }; - this.handleChange = this.handleChange.bind(this); - } + state = { + data: [], + }; componentDidMount() { this.setState({ data }); } - handleChange(value, data, extra) { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); - } + }; render() { return ( console.log(e)); } - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/change-on-select/index.tsx b/components/cascader-select/__docs__/demo/change-on-select/index.tsx index e861a2d307..24168f9089 100644 --- a/components/cascader-select/__docs__/demo/change-on-select/index.tsx +++ b/components/cascader-select/__docs__/demo/change-on-select/index.tsx @@ -1,16 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - }; - } + state = { + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -19,7 +16,7 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/check-strictyle/index.tsx b/components/cascader-select/__docs__/demo/check-strictyle/index.tsx index 549f375d52..0286b6f33e 100644 --- a/components/cascader-select/__docs__/demo/check-strictyle/index.tsx +++ b/components/cascader-select/__docs__/demo/check-strictyle/index.tsx @@ -1,17 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox, CascaderSelect } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - checkStrictly: false, - }; - } + state = { + data: [], + checkStrictly: false, + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -26,7 +23,7 @@ class Demo extends React.Component { }); }; - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/custom-style/index.tsx b/components/cascader-select/__docs__/demo/custom-style/index.tsx index 1d21f58e7d..c156f1f4a4 100644 --- a/components/cascader-select/__docs__/demo/custom-style/index.tsx +++ b/components/cascader-select/__docs__/demo/custom-style/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect, Icon } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; const dataSource = [ { @@ -41,7 +42,7 @@ const dataSource = [ }, ]; -const itemRender = item => { +const itemRender: CascaderSelectProps['itemRender'] = item => { return ( {item.label} diff --git a/components/cascader-select/__docs__/demo/custom/index.tsx b/components/cascader-select/__docs__/demo/custom/index.tsx index 44fd2d97ee..08d07a4fe5 100644 --- a/components/cascader-select/__docs__/demo/custom/index.tsx +++ b/components/cascader-select/__docs__/demo/custom/index.tsx @@ -1,18 +1,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; - import { CascaderSelect } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - }; - this.handleChange = this.handleChange.bind(this); - } + state = { + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -24,16 +19,16 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); - } + }; - valueRender = item => { + valueRender: CascaderSelectProps['valueRender'] = item => { if (item.label) { - return item.label; // 正常的item + return item.label; // 正常的 item } - // value在 dataSouce里不存在时渲染。 + // value 在 dataSouce 里不存在时渲染。 return item.value === '432988' ? '不存在的值' : item.value; }; diff --git a/components/cascader-select/__docs__/demo/disabled/index.tsx b/components/cascader-select/__docs__/demo/disabled/index.tsx index 79da58d3c2..ba68268d7b 100644 --- a/components/cascader-select/__docs__/demo/disabled/index.tsx +++ b/components/cascader-select/__docs__/demo/disabled/index.tsx @@ -2,46 +2,43 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - isLeaf: true, - checkboxDisabled: true, - }, - { value: '2976', label: '高陵县', isLeaf: true }, - ], - }, - { - value: '2980', - label: '铜川', - disabled: true, - children: [ - { value: '2981', label: '铜川市', isLeaf: true }, - { value: '2982', label: '宜君县', isLeaf: true }, - ], - }, - ], - }, - ], - }; - } + state = { + data: [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + isLeaf: true, + checkboxDisabled: true, + }, + { value: '2976', label: '高陵县', isLeaf: true }, + ], + }, + { + value: '2980', + label: '铜川', + disabled: true, + children: [ + { value: '2981', label: '铜川市', isLeaf: true }, + { value: '2982', label: '宜君县', isLeaf: true }, + ], + }, + ], + }, + ], + }; - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/dynamic/index.tsx b/components/cascader-select/__docs__/demo/dynamic/index.tsx index 4a92dfb6c4..4f549ef413 100644 --- a/components/cascader-select/__docs__/demo/dynamic/index.tsx +++ b/components/cascader-select/__docs__/demo/dynamic/index.tsx @@ -2,6 +2,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; const dataSource = [ { @@ -11,20 +12,14 @@ const dataSource = [ ]; class Demo extends React.Component { - constructor(props) { - super(props); + state = { + dataSource, + }; - this.state = { - dataSource, - }; - - this.onLoadData = this.onLoadData.bind(this); - } - - onLoadData(data) { + onLoadData: CascaderSelectProps['loadData'] = data => { console.log(data); - return new Promise(resolve => { + return new Promise(resolve => { setTimeout(() => { this.setState( { @@ -57,7 +52,7 @@ class Demo extends React.Component { ); }, 500); }); - } + }; render() { return ; diff --git a/components/cascader-select/__docs__/demo/expand-trigger/index.tsx b/components/cascader-select/__docs__/demo/expand-trigger/index.tsx index 5846dc0410..37ad81bbcd 100644 --- a/components/cascader-select/__docs__/demo/expand-trigger/index.tsx +++ b/components/cascader-select/__docs__/demo/expand-trigger/index.tsx @@ -2,21 +2,16 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Radio, CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; +import type { RadioProps } from '@alifd/next/types/radio'; const RadioGroup = Radio.Group; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - triggerType: 'click', - data: [], - }; - - this.handleChange = this.handleChange.bind(this); - this.handleTriggerTypeChange = this.handleTriggerTypeChange.bind(this); - } + state = { + triggerType: 'click' as const, + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -25,15 +20,15 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); - } + }; - handleTriggerTypeChange(triggerType) { + handleTriggerTypeChange: RadioProps['onChange'] = triggerType => { this.setState({ triggerType, }); - } + }; render() { return ( diff --git a/components/cascader-select/__docs__/demo/expanded-value/index.tsx b/components/cascader-select/__docs__/demo/expanded-value/index.tsx index 0bf7bc514c..4bdb7b9c8a 100644 --- a/components/cascader-select/__docs__/demo/expanded-value/index.tsx +++ b/components/cascader-select/__docs__/demo/expanded-value/index.tsx @@ -2,40 +2,37 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { value: '2975', label: '西安市', isLeaf: true }, - { value: '2976', label: '高陵县', isLeaf: true }, - ], - }, - { - value: '2980', - label: '铜川', - children: [ - { value: '2981', label: '铜川市', isLeaf: true }, - { value: '2982', label: '宜君县', isLeaf: true }, - ], - }, - ], - }, - ], - }; - } + state = { + data: [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市', isLeaf: true }, + { value: '2976', label: '高陵县', isLeaf: true }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市', isLeaf: true }, + { value: '2982', label: '宜君县', isLeaf: true }, + ], + }, + ], + }, + ], + }; - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/multiple/index.tsx b/components/cascader-select/__docs__/demo/multiple/index.tsx index 1f869aae2d..f0d9aef090 100644 --- a/components/cascader-select/__docs__/demo/multiple/index.tsx +++ b/components/cascader-select/__docs__/demo/multiple/index.tsx @@ -2,17 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - }; - - this.handleChange = this.handleChange.bind(this); - } + state = { + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -23,9 +18,9 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); - } + }; render() { return ( diff --git a/components/cascader-select/__docs__/demo/only-leaf/index.tsx b/components/cascader-select/__docs__/demo/only-leaf/index.tsx index ec8edf1fab..1ea22d0317 100644 --- a/components/cascader-select/__docs__/demo/only-leaf/index.tsx +++ b/components/cascader-select/__docs__/demo/only-leaf/index.tsx @@ -2,15 +2,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { CascaderSelect } from '@alifd/next'; import 'whatwg-fetch'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - }; - } + state = { + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -19,7 +16,7 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader-select/__docs__/demo/search-async/index.tsx b/components/cascader-select/__docs__/demo/search-async/index.tsx index cf43fd516a..4e2b69c922 100644 --- a/components/cascader-select/__docs__/demo/search-async/index.tsx +++ b/components/cascader-select/__docs__/demo/search-async/index.tsx @@ -1,9 +1,10 @@ import React, { useState, useEffect } from 'react'; import ReactDOM from 'react-dom'; -import { CascaderSelect, Icon } from '@alifd/next'; +import { CascaderSelect } from '@alifd/next'; +import type { CascaderSelectProps } from '@alifd/next/types/cascader-select'; function Demo() { - const [data, setData] = useState([]); + const [data, setData] = useState>([]); const [loading, setLoading] = useState(false); useEffect(() => { @@ -15,21 +16,21 @@ function Demo() { .catch(e => console.log(e)); }, []); - let timeId; + let timeId: number | undefined; const duration = 1000; - function handleSearch(searchVal) { + const handleSearch: CascaderSelectProps['onSearch'] = searchVal => { setLoading(true); if (timeId) { clearTimeout(timeId); } - timeId = setTimeout(() => { + timeId = window.setTimeout(() => { if (searchVal) { - const item = { ...data[0].children[0].children[0] }; + const item = { ...data[0].children![0].children![0] }; item.label = `${searchVal}_${item.label}`; item.value = `${Date.now()}`; - data[0].children[0].children[0] = item; + data[0].children![0].children![0] = item; setData([...data]); } @@ -37,7 +38,7 @@ function Demo() { timeId = undefined; setLoading(false); }, duration); - } + }; return ( console.log(e)); } - handleChange = (value, data, extra) => { + handleChange: CascaderSelectProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; - filter(searchValue, path) { - return ( + filter: CascaderSelectProps['filter'] = (searchValue, path) => { + return !!( searchValue === '' || path .map(({ value, label }) => `${value}${label}`) .join('') .match(searchValue) ); - } + }; render() { return ( diff --git a/components/cascader-select/__docs__/index.en-us.md b/components/cascader-select/__docs__/index.en-us.md index 358a980f83..2fdf285860 100644 --- a/components/cascader-select/__docs__/index.en-us.md +++ b/components/cascader-select/__docs__/index.en-us.md @@ -17,90 +17,81 @@ CascaderSelect consists of Select and Cascader. Cascader are hidden in a pop up ### CascaderSelect -| Param | Description | Type | Default Value | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ---------------------------- | -| size | size of selector

    **options**:
    'small', 'medium', 'large' | Enum | 'medium' | -| placeholder | placeholder of selector | String | - | -| disabled | whether is disabled | Boolean | false | -| hasArrow | whether has arrow icon | Boolean | true | -| hasBorder | whether selector has border | Boolean | true | -| hasClear | whether has clear button | Boolean | false | -| label | custom inline label | ReactNode | - | -| readOnly | whether selector is read only, it can be expanded but cannot be selected under read only mode | Boolean | - | -| dataSource | data source, structure can refer to the following document | Array<Object> | \[] | -| defaultValue | (under uncontrol) default value | String/Array<String> | null | -| value | (under control) current value | String/Array<String> | - | -| onChange | callback triggered when value changes

    **signatures**:
    Function(value: String/Array, data: Object/Array, extra: Object) => void
    **params**:
    _value_: {String/Array} selected value, a single value is returned when single select, and an array is returned when multiple select
    _data_: {Object/Array} selected data, including value, label, returns a single value when single select, returns an array when multiple select, parent and child nodes are selected at the same time, only the parent node is returned
    _extra_: {Object} extra param
    _extra.selectedPath_: {Array} path of the selected data when single selecte
    _extra.checked_: {Boolean} whether is checked when multiple select
    _extra.currentData_: {Object} current operation data when multiple select
    _extra.checkedData_: {Array} all checked data when multiple select
    _extra.indeterminateData_: {Array} indeterminate data when multile selec | Function | - | -| defaultExpandedValue | (under uncontrol) default expanded value, if not set, the component will be automatically set according to defaultValue/value | Array<String> | - | -| expandedValue | (under control) current expanded value | Array<String> | - | -| expandTriggerType | expand trigger type

    **options**:
    'click', 'hover' | Enum | 'click' | -| multiple | whether is multiple select | Boolean | false | -| changeOnSelect | change immediately if selected, this property is only worked in single selection mode | Boolean | false | -| canOnlyCheckLeaf | whether checkbox of leaf item can only be checked, this property is only worked in multiple selection mode | Boolean | false | -| checkStrictly | whether selection of parent and child nodes are related | Boolean | false | -| listStyle | style of list | Object | - | -| listClassName | class name of list | String | - | -| displayRender | custom rendering function to display results

    **signatures**:
    Function(label: Array) => ReactNode
    **params**:
    _label_: {Array} label array of the selected path
    **returns**:
    {ReactNode} display content
    | Function | single select: labelPath => labelPath.join(' / '); multiple select: labelPath => labelPath[labelPath.length - 1] | -| showSearch | whether to show the search box | Boolean | false | -| onSearch | Callback when the search box value changes

    **Signature**:
    Function(value: String) => void
    **Parameters**:
    _value_: {String } Data | Function | func.noop | -| filter | custom search function

    **signatures**:
    Function(searchValue: String, path: Array) => Boolean
    **params**:
    _searchValue_: {String} search keyword
    _path_: {Array} item path
    **returns**:
    {Boolean} whether is matched
    | Function | fuzzy matching of label based on all nodes of the path | -| resultRender | custom render function of search result

    **signatures**:
    Function(searchValue: String, path: Array) => ReactNode
    **params**:
    _searchValue_: {String} search keyword
    _path_: {Array} matched item path
    **returns**:
    {ReactNode} result content
    | Function | rendering by pattern of a / b / c | -| resultAutoWidth | whether the search result list is equal to the selector | Boolean | true | -| notFoundContent | content without data | ReactNode | 'Not Found' | -| loadData | asynchronous data loading function

    **signatures**:
    Function(data: Object) => void
    **params**:
    _data_: {Object} data of the clicked item | Function | - | -| header | custom dropdown header | ReactNode | - | -| footer | custom dropdown footer | ReactNode | - | -| defaultVisible | whether dropdown is default visible | Boolean | false | -| visible | whether dropdown is current visible | Boolean | - | -| onVisibleChange | callback triggered when open or close dropdown

    **signatures**:
    Function(visible: Boolean, type: String) => void
    **params**:
    _visible_: {Boolean} whether is visible
    _type_: {String} trigger type | Function | () => {} | -| popupStyle | style of dropdown | Object | - | -| popupClassName | className of dropdown | String | - | -| popupContainer | container of dropdown | String/Function | - | -| popupProps | properties of Popup | Object | {} | -| followTrigger | follow Trigger or not | Boolean | - | -| immutable | whether allow immutable dataSource | Boolean | false | 1.23 | +Inherits partial props from Cascader, support passing props to Cascader: dataSource, useVirtual, multiple, canOnlyCheckLeaf, +checkStrictly, resultRender, expandedValue, defaultExpandedValue, expandTriggerType, onExpand, listStyle, listClassName, loadData, i +temRender, immutable. Support passing props to Select: other Select props except those listed above and those listed below. + +| Param | Description | Type | Default Value | Required | Supported Version | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -------- | ----------------- | +| size | Size | 'small' \| 'medium' \| 'large' | 'medium' | | - | +| disabled | Disabled | boolean | false | | - | +| hasArrow | HasArrow | boolean | true | | - | +| hasBorder | HasBorder | boolean | true | | - | +| hasClear | HasClear | boolean | false | | - | +| readOnly | ReadOnly, popup layer can be expanded but cannot be selected in read | boolean | - | | - | +| defaultValue | Default value(not controlled) | string \| Array\ | - | | - | +| value | Current value(controlled) | string \| Array\ | - | | - | +| onChange | Callback when selected value changes | (
    value: string \| Array\ \| null,
    data: CascaderDataItem \| Array\ \| null,
    extra?: Extra
    ) => void | - | | - | +| changeOnSelect | Whether to call onChange as soon as selected, this property only works in single selection mode | boolean | false | | - | +| displayRender | Custom render function of selected result | (
    label: Array\,
    data: CascaderSelectDataItem
    ) => React.ReactNode | - | | - | +| showSearch | Show search box | boolean | false | | - | +| filter | Custom search function | (searchValue: string, path: CascaderSelectDataItem[]) => boolean | - | | - | +| onSearch | Callback when search value changes | (value: string) => void | - | | 1.23 | +| resultAutoWidth | Whether the search result list is the same width as the selection box | boolean | true | | - | +| notFoundContent | Content when no data | React.ReactNode | - | | - | +| header | Custom dropdown header | React.ReactNode | - | | - | +| footer | Custom dropdown footer | React.ReactNode | - | | - | +| defaultVisible | Visible by default | boolean | false | | - | +| visible | Current visible | boolean | - | | - | +| onVisibleChange | - | (visible: boolean, type?: CascaderSelectVisibleChangeType) => void | - | | - | +| popupProps | Props object passed to Popup | React.ComponentPropsWithRef\ | - | | - | +| isPreview | Whether it is in preview mode | boolean | false | | - | +| renderPreview | Custom preview | (
    value: CascaderSelectDataItem \| CascaderSelectDataItem[],
    props: CascaderSelectProps
    ) => React.ReactNode | - | | - | +| menuProps | Props object passed to Cascader:The parameters focusedKey, onItemFocus, className, style, focusable, and isSelectIconRight are invalid. Additionally, onBlur is invalid when passed under the filter | Omit\ | - | | - | +| autoClearSearchValue | Whether the current search will be cleared on selecting an item. Only applies when multiple is true | boolean | false | | - | ### Data structure of dataSource ```js -const dataSource = [{ - value: '2974', - label: '西安', - children: [ - { value: '2975', label: '西安市', disabled: true }, - { value: '2976', label: '高陵县', checkboxDisabled: true }, - { value: '2977', label: '蓝田县' }, - { value: '2978', label: '户县' }, - { value: '2979', label: '周至县' }, - { value: '4208', label: '灞桥区' }, - { value: '4209', label: '长安区' }, - { value: '4210', label: '莲湖区' }, - { value: '4211', label: '临潼区' }, - { value: '4212', label: '未央区' }, - { value: '4213', label: '新城区' }, - { value: '4214', label: '阎良区' }, - { value: '4215', label: '雁塔区' }, - { value: '4388', label: '碑林区' }, - { value: '610127', label: '其它区' } - ] -}]; +const dataSource = [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市', disabled: true }, + { value: '2976', label: '高陵县', checkboxDisabled: true }, + { value: '2977', label: '蓝田县' }, + { value: '2978', label: '户县' }, + { value: '2979', label: '周至县' }, + { value: '4208', label: '灞桥区' }, + { value: '4209', label: '长安区' }, + { value: '4210', label: '莲湖区' }, + { value: '4211', label: '临潼区' }, + { value: '4212', label: '未央区' }, + { value: '4213', label: '新城区' }, + { value: '4214', label: '阎良区' }, + { value: '4215', label: '雁塔区' }, + { value: '4388', label: '碑林区' }, + { value: '610127', label: '其它区' }, + ], + }, +]; ``` The custom attribute of item in the array is also transparently passed to the data parameter of the onChange function. - ## ARIA and KeyBoard -| 按键 | 说明 | -| :---------- | :------------------------------ | -| Up Arrow | Get the previous item focus of the current item of same level | -| Down Arrow | Get the next item focus of the current item of same level | +| 按键 | 说明 | +| :---------- | :--------------------------------------------------------------------------------------- | +| Up Arrow | Get the previous item focus of the current item of same level | +| Down Arrow | Get the next item focus of the current item of same level | | Left Arrow | Enter the child element of the current item and get the first child element as the focus | -| Right Arrow | Returns the parent of the current item and gets the focus | -| Enter | Open the directory or select current item | -| Esc | Close the directory | -| SPACE | Select current item | +| Right Arrow | Returns the parent of the current item and gets the focus | +| Enter | Open the directory or select current item | +| Esc | Close the directory | +| SPACE | Select current item | diff --git a/components/cascader-select/__docs__/index.md b/components/cascader-select/__docs__/index.md index 7f9e01030e..960a60f7d9 100644 --- a/components/cascader-select/__docs__/index.md +++ b/components/cascader-select/__docs__/index.md @@ -17,79 +17,67 @@ ### CascaderSelect -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | --------------------------------------------------------------------------------------- | ---- | -| size | 选择框大小

    **可选值**:
    'small', 'medium', 'large' | Enum | 'medium' | | -| placeholder | 选择框占位符 | String | - | | -| disabled | 是否禁用 | Boolean | false | | -| hasArrow | 是否有下拉箭头 | Boolean | true | | -| hasBorder | 是否有边框 | Boolean | true | | -| hasClear | 是否有清除按钮 | Boolean | false | | -| label | 自定义内联 label | ReactNode | - | | -| readOnly | 是否只读,只读模式下可以展开弹层但不能选 | Boolean | - | | -| dataSource | 数据源,结构可参考下方说明 | Array<Object> | \[] | | -| defaultValue | (非受控)默认值 | String/Array<String> | null | | -| value | (受控)当前值 | String/Array<String> | - | | -| onChange | 选中值改变时触发的回调函数

    **签名**:
    Function(value: String/Array, data: Object/Array, extra: Object) => void
    **参数**:
    _value_: {String/Array} 选中的值,单选时返回单个值,多选时返回数组
    _data_: {Object/Array} 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点
    _extra_: {Object} 额外参数
    _extra.selectedPath_: {Array} 单选时选中的数据的路径
    _extra.checked_: {Boolean} 多选时当前的操作是选中还是取消选中
    _extra.currentData_: {Object} 多选时当前操作的数据
    _extra.checkedData_: {Array} 多选时所有被选中的数据
    _extra.indeterminateData_: {Array} 多选时半选的数据 | Function | - | | -| defaultExpandedValue | 默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 | Array<String> | - | | -| expandedValue | (受控)当前展开值 | Array<String> | - | | -| expandTriggerType | 展开触发的方式

    **可选值**:
    'click', 'hover' | Enum | 'click' | | -| useVirtual | 是否开启虚拟滚动 | Boolean | false | | -| multiple | 是否多选 | Boolean | false | | -| changeOnSelect | 是否选中即发生改变, 该属性仅在单选模式下有效 | Boolean | false | | -| canOnlyCheckLeaf | 是否只能勾选叶子项的checkbox,该属性仅在多选模式下有效 | Boolean | false | | -| checkStrictly | 父子节点是否选中不关联 | Boolean | false | | -| listStyle | 每列列表样式对象 | Object | - | | -| listClassName | 每列列表类名 | String | - | | -| displayRender | 选择框单选时展示结果的自定义渲染函数

    **签名**:
    Function(label: Array) => ReactNode
    **参数**:
    _label_: {Array} 选中路径的文本数组
    **返回值**:
    {ReactNode} 渲染在选择框中的内容
    | Function | 单选时:labelPath => labelPath.join(' / ');多选时:labelPath => labelPath[labelPath.length - 1] | | -| itemRender | 渲染 item 内容的方法

    **签名**:
    Function(item: Object) => ReactNode
    **参数**:
    _item_: {Object} 渲染节点的item
    **返回值**:
    {ReactNode} item node
    | Function | - | | -| showSearch | 是否显示搜索框 | Boolean | false | | -| filter | 自定义搜索函数

    **签名**:
    Function(searchValue: String, path: Array) => Boolean
    **参数**:
    _searchValue_: {String} 搜索的关键字
    _path_: {Array} 节点路径
    **返回值**:
    {Boolean} 是否匹配
    | Function | 根据路径所有节点的文本值模糊匹配 | | -| onSearch | 当搜索框值变化时回调

    **签名**:
    Function(value: String) => void
    **参数**:
    _value_: {String} 数据 | Function | - | 1.23 | -| resultRender | 搜索结果自定义渲染函数

    **签名**:
    Function(searchValue: String, path: Array) => ReactNode
    **参数**:
    _searchValue_: {String} 搜索的关键字
    _path_: {Array} 匹配到的节点路径
    **返回值**:
    {ReactNode} 渲染的内容
    | Function | 按照节点文本 a / b / c 的模式渲染 | | -| resultAutoWidth | 搜索结果列表是否和选择框等宽 | Boolean | true | | -| notFoundContent | 无数据时显示内容 | ReactNode | - | | -| loadData | 异步加载数据函数

    **签名**:
    Function(data: Object) => void
    **参数**:
    _data_: {Object} 当前点击异步加载的数据 | Function | - | | -| header | 自定义下拉框头部 | ReactNode | - | | -| footer | 自定义下拉框底部 | ReactNode | - | | -| defaultVisible | 初始下拉框是否显示 | Boolean | false | | -| visible | 当前下拉框是否显示 | Boolean | - | | -| onVisibleChange | 下拉框显示或关闭时触发事件的回调函数

    **签名**:
    Function(visible: Boolean, type: String) => void
    **参数**:
    _visible_: {Boolean} 是否显示
    _type_: {String} 触发显示关闭的操作类型, fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 | Function | () => {} | | -| popupStyle | 下拉框自定义样式对象 | Object | - | | -| popupClassName | 下拉框样式自定义类名 | String | - | | -| popupContainer | 下拉框挂载的容器节点 | any | - | | -| popupProps | 透传到 Popup 的属性对象 | Object | {} | | -| followTrigger | 是否跟随滚动 | Boolean | - | | -| isPreview | 是否为预览态 | Boolean | - | | -| renderPreview | 预览态模式下渲染的内容

    **签名**:
    Function(value: Array) => void
    **参数**:
    _value_: {Array} 选择值 { label: , value:} | Function | - | | -| immutable | 是否是不可变数据 | Boolean | false | 1.23 | +继承 Cascader, Select 的部分属性,支持透传给 Cascader 的属性有 dataSource, useVirtual, multiple, canOnlyCheckLeaf, +checkStrictly, resultRender, expandedValue, defaultExpandedValue, expandTriggerType, onExpand, listStyle, +listClassName, loadData, itemRender, immutable。支持透传给 Select 的包括除上面传给 Cascader 和下方单独列出的属性以外的其他全部属性。 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| -------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | -------- | -------- | +| size | 选择框大小 | 'small' \| 'medium' \| 'large' | 'medium' | | - | +| disabled | 是否禁用 | boolean | false | | - | +| hasArrow | 是否有下拉箭头 | boolean | true | | - | +| hasBorder | 是否有边框 | boolean | true | | - | +| hasClear | 是否有清除按钮 | boolean | false | | - | +| readOnly | 是否只读,只读模式下可以展开弹层但不能选 | boolean | - | | - | +| defaultValue | (非受控)默认值 | string \| Array\ | - | | - | +| value | (受控)当前值 | string \| Array\ | - | | - | +| onChange | 选中值改变时触发的回调函数 | (
    value: string \| Array\ \| null,
    data: CascaderDataItem \| Array\ \| null,
    extra?: Extra
    ) => void | - | | - | +| changeOnSelect | 是否选中即发生改变,该属性仅在单选模式下有效 | boolean | false | | - | +| displayRender | 选择框单选时展示结果的自定义渲染函数 | (
    label: Array\,
    data: CascaderSelectDataItem
    ) => React.ReactNode | - | | - | +| showSearch | 是否显示搜索框 | boolean | false | | - | +| filter | 自定义搜索函数 | (searchValue: string, path: CascaderSelectDataItem[]) => boolean | - | | - | +| onSearch | 当搜索框值变化时回调 | (value: string) => void | - | | 1.23 | +| resultAutoWidth | 搜索结果列表是否和选择框等宽 | boolean | true | | - | +| notFoundContent | 无数据时显示内容 | React.ReactNode | - | | - | +| header | 自定义下拉框头部 | React.ReactNode | - | | - | +| footer | 自定义下拉框底部 | React.ReactNode | - | | - | +| defaultVisible | 初始下拉框是否显示 | boolean | false | | - | +| visible | 当前下拉框是否显示 | boolean | - | | - | +| onVisibleChange | 下拉框显示或关闭时触发事件的回调函数 | (visible: boolean, type?: CascaderSelectVisibleChangeType) => void | - | | - | +| popupProps | 透传到 Popup 的属性对象 | React.ComponentPropsWithRef\ | - | | - | +| isPreview | 是否为预览态 | boolean | false | | - | +| renderPreview | 自定义预览态 | (
    value: CascaderSelectDataItem \| CascaderSelectDataItem[],
    props: CascaderSelectProps
    ) => React.ReactNode | - | | - | +| menuProps | 透传到 Cascader 的属性对象;focusedKey、onItemFocus、className、style、focusable、isSelectIconRight 传入无效,其中 onBlur 在 filter 下传入无效 | Omit\ | - | | - | +| autoClearSearchValue | 是否在选中项后清空搜索框,只在 multiple 为 true 时有效 | boolean | false | | - | ### dataSource数据结构 ```js -const dataSource = [{ - value: '2974', - label: '西安', - children: [ - { value: '2975', label: '西安市', disabled: true }, - { value: '2976', label: '高陵县', checkboxDisabled: true }, - { value: '2977', label: '蓝田县' }, - { value: '2978', label: '户县' }, - { value: '2979', label: '周至县' }, - { value: '4208', label: '灞桥区' }, - { value: '4209', label: '长安区' }, - { value: '4210', label: '莲湖区' }, - { value: '4211', label: '临潼区' }, - { value: '4212', label: '未央区' }, - { value: '4213', label: '新城区' }, - { value: '4214', label: '阎良区' }, - { value: '4215', label: '雁塔区' }, - { value: '4388', label: '碑林区' }, - { value: '610127', label: '其它区' } - ] -}]; +const dataSource = [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市', disabled: true }, + { value: '2976', label: '高陵县', checkboxDisabled: true }, + { value: '2977', label: '蓝田县' }, + { value: '2978', label: '户县' }, + { value: '2979', label: '周至县' }, + { value: '4208', label: '灞桥区' }, + { value: '4209', label: '长安区' }, + { value: '4210', label: '莲湖区' }, + { value: '4211', label: '临潼区' }, + { value: '4212', label: '未央区' }, + { value: '4213', label: '新城区' }, + { value: '4214', label: '阎良区' }, + { value: '4215', label: '雁塔区' }, + { value: '4388', label: '碑林区' }, + { value: '610127', label: '其它区' }, + ], + }, +]; ``` 数组中 Item 的自定义属性也会被透传到 onChange 函数的 data 参数中。 @@ -98,12 +86,12 @@ const dataSource = [{ ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---------- | :--------------------- | -| Up Arrow | 获取同级当前项前一项焦点 | -| Down Arrow | 获取同级当前项后一项焦点 | +| 按键 | 说明 | +| :---------- | :------------------------------------------- | +| Up Arrow | 获取同级当前项前一项焦点 | +| Down Arrow | 获取同级当前项后一项焦点 | | Left Arrow | 进入当前项的子元素,并获取第一个子元素为焦点 | -| Right Arrow | 返回当前项的父元素并获取焦点 | -| Enter | 打开目录或选择当前项 | -| Esc | 关闭目录 | -| SPACE | 选择当前项 | +| Right Arrow | 返回当前项的父元素并获取焦点 | +| Enter | 打开目录或选择当前项 | +| Esc | 关闭目录 | +| SPACE | 选择当前项 | diff --git a/components/cascader-select/__docs__/theme/index.jsx b/components/cascader-select/__docs__/theme/index.jsx deleted file mode 100644 index 01451bc9ac..0000000000 --- a/components/cascader-select/__docs__/theme/index.jsx +++ /dev/null @@ -1,153 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import '../../style'; -import { Demo, DemoGroup, DemoHead, initDemo } from '../../../demo-helper'; -import CascaderSelect from '../../index'; -import ConfigProvider from '../../../config-provider'; -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; - -const i18nMap = { - 'en-us': { - label: 'Label' - }, - 'zh-cn': { - label: '标签:' - } -}; - -const createDataSource = () => { - const dataSource = []; - - for (let i = 0; i < 10; i++) { - const level1 = { - label: `${i}`, - value: `${i}`, - children: [] - }; - dataSource.push(level1); - for (let j = 0; j < 10; j++) { - const level2 = { - label: `${i}-${j}`, - value: `${i}-${j}`, - children: [] - }; - level1.children.push(level2); - for (let k = 0; k < 10; k++) { - const level3 = { - label: `${i}-${j}-${k}`, - value: `${i}-${j}-${k}` - }; - level2.children.push(level3); - } - } - } - - dataSource[1].disabled = true; - - return dataSource; -}; - -class FunctionDemo extends React.Component { - constructor(props) { - super(props); - this.state = { - demoFunction: { - hasBorder: { - label: '有无边框', - value: 'true', - enum: [{ - label: '有', - value: 'true' - }, { - label: '无', - value: 'false' - }] - }, - inlineLabel: { - label: '是否内置标签', - value: 'false', - enum: [{ - label: '有', - value: 'true' - }, { - label: '无', - value: 'false' - }] - } - } - }; - - this.onFunctionChange = this.onFunctionChange.bind(this); - } - - onFunctionChange(demoFunction) { - this.setState({ - demoFunction - }); - } - - render() { - const dataSource = createDataSource(); - // eslint-disable-next-line - const { multiple, i18n } = this.props; - const { demoFunction } = this.state; - const hasBorder = demoFunction.hasBorder.value === 'true'; - const inlineLabel = demoFunction.inlineLabel.value === 'true'; - const cascaderSelectProps = { - multiple, - dataSource, - hasBorder, - style: { width: '300px' } - }; - if (inlineLabel) { - cascaderSelectProps.label = i18n.label; - } - - return ( - - - - - - - - - - - - - - - - - - - - - ); - } -} - - -function render(lang = 'en-us') { - const i18n = i18nMap[lang]; - - ReactDOM.render(( - -
    - - -
    -
    - ), document.getElementById('container')); -} - -window.renderDemo = function(lang) { - render(lang); -}; - -window.renderDemo(); - -initDemo('cascader-select'); diff --git a/components/cascader-select/__docs__/theme/index.tsx b/components/cascader-select/__docs__/theme/index.tsx new file mode 100644 index 0000000000..9a750815b6 --- /dev/null +++ b/components/cascader-select/__docs__/theme/index.tsx @@ -0,0 +1,197 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import '../../style'; +import { Demo, DemoGroup, DemoHead, type DemoProps, initDemo } from '../../../demo-helper'; +import CascaderSelect, { type CascaderSelectProps } from '../../index'; +import ConfigProvider from '../../../config-provider'; +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; +import { type CascaderDataItem } from '../../../cascader'; + +const i18nMap = { + 'en-us': { + label: 'Label', + }, + 'zh-cn': { + label: '标签:', + }, +} as const; + +const createDataSource = () => { + const dataSource: CascaderSelectProps['dataSource'] = []; + + for (let i = 0; i < 10; i++) { + const level1: CascaderDataItem = { + label: `${i}`, + value: `${i}`, + children: [], + }; + dataSource.push(level1); + for (let j = 0; j < 10; j++) { + const level2: CascaderDataItem = { + label: `${i}-${j}`, + value: `${i}-${j}`, + children: [], + }; + level1.children!.push(level2); + for (let k = 0; k < 10; k++) { + const level3 = { + label: `${i}-${j}-${k}`, + value: `${i}-${j}-${k}`, + }; + level2.children!.push(level3); + } + } + } + + dataSource[1].disabled = true; + + return dataSource; +}; + +class FunctionDemo extends React.Component<{ + multiple?: CascaderSelectProps['multiple']; + i18n: (typeof i18nMap)[keyof typeof i18nMap]; +}> { + state = { + demoFunction: { + hasBorder: { + label: '有无边框', + value: 'true', + enum: [ + { + label: '有', + value: 'true', + }, + { + label: '无', + value: 'false', + }, + ], + }, + inlineLabel: { + label: '是否内置标签', + value: 'false', + enum: [ + { + label: '有', + value: 'true', + }, + { + label: '无', + value: 'false', + }, + ], + }, + }, + }; + + onFunctionChange: DemoProps['onFunctionChange'] = demoFunction => { + this.setState({ + demoFunction, + }); + }; + + render() { + const dataSource = createDataSource(); + const { multiple, i18n } = this.props; + const { demoFunction } = this.state; + const hasBorder = demoFunction.hasBorder.value === 'true'; + const inlineLabel = demoFunction.inlineLabel.value === 'true'; + const cascaderSelectProps: CascaderSelectProps = { + multiple, + dataSource, + hasBorder, + style: { width: '300px' }, + }; + if (inlineLabel) { + cascaderSelectProps.label = i18n.label; + } + + return ( + + + + + + + + + + + + + + + + + + + + + ); + } +} + +function render(lang = 'en-us') { + const i18n = i18nMap[lang as keyof typeof i18nMap]; + + ReactDOM.render( + +
    + + +
    +
    , + document.getElementById('container') + ); +} + +window.renderDemo = function (lang) { + render(lang); +}; + +window.renderDemo(); + +initDemo('cascader-select'); diff --git a/components/cascader-select/__tests__/a11y-spec.js b/components/cascader-select/__tests__/a11y-spec.js deleted file mode 100644 index 97b85fdc9e..0000000000 --- a/components/cascader-select/__tests__/a11y-spec.js +++ /dev/null @@ -1,52 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import CascaderSelect from '../index'; -import '../style'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ - adapter: new Adapter(), -}); - -const ChinaArea = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [{ value: '2975', label: '西安市' }, { value: '2976', label: '高陵县' }], - }, - { - value: '2980', - label: '铜川', - children: [{ value: '2981', label: '铜川市' }, { value: '2982', label: '宜君县' }], - }, - ], - }, - { - value: '3078', - label: '四川', - }, -]; - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('CascaderSelect A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - // TODO Select support a11y - it.skip('should not have any violations when empty', async () => { - wrapper = await testReact(); - return wrapper; - }); -}); diff --git a/components/cascader-select/__tests__/a11y-spec.tsx b/components/cascader-select/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..47599c97a9 --- /dev/null +++ b/components/cascader-select/__tests__/a11y-spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import CascaderSelect from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +const ChinaArea = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3078', + label: '四川', + }, +]; + +describe('CascaderSelect A11y', () => { + it('should not have any violations', async () => { + await testReact(); + }); +}); diff --git a/components/cascader-select/__tests__/index-spec.js b/components/cascader-select/__tests__/index-spec.js deleted file mode 100644 index 979d80070c..0000000000 --- a/components/cascader-select/__tests__/index-spec.js +++ /dev/null @@ -1,613 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils, { act } from 'react-dom/test-utils'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import { KEYCODE } from '../../util'; -import CascaderSelect from '../index'; -import '../style'; - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ - -Enzyme.configure({ adapter: new Adapter() }); - -function freeze(dataSource) { - return dataSource.map(item => { - const { children } = item; - children && freeze(children); - return Object.freeze({ ...item }); - }); -} - -const delay = time => new Promise(resolve => setTimeout(resolve, time)); - -const ChinaArea = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [{ value: '2975', label: '西安市' }, { value: '2976', label: '高陵县' }], - }, - { - value: '2980', - label: '铜川', - children: [{ value: '2981', label: '铜川市' }, { value: '2982', label: '宜君县' }], - }, - ], - }, - { - value: '3078', - label: '四川', - }, -]; - -describe('CascaderSelect', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should show dropdown when set defaultVisible to true', () => { - wrapper = mount(); - assert(document.querySelector('.next-cascader-select-dropdown')); - }); - - it('should show dropdown when click select box', done => { - wrapper = mount(); - assert(!document.querySelector('.next-cascader-select-dropdown')); - wrapper.find('.next-select').simulate('click'); - setTimeout(() => { - assert(document.querySelector('.next-cascader-select-dropdown')); - done(); - }, 500); - }); - - it('should render single cascader select', () => { - let changeCalled = false; - const handleChange = () => { - changeCalled = true; - }; - - wrapper = mount( - - ); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 西安市' - ); - - const item21 = findItem(2, 1); - ReactTestUtils.Simulate.click(item21); - assert(changeCalled); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 高陵县' - ); - wrapper.setProps({ displayRender: label => label.join('-') }); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西-西安-高陵县' - ); - }); - - it('should render single cascader under control', () => { - let changeCalled = false; - const handleChange = value => { - changeCalled = true; - wrapper.setProps({ value }); - }; - - wrapper = mount( - - ); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 西安市' - ); - - const item21 = findItem(2, 1); - ReactTestUtils.Simulate.click(item21); - assert(changeCalled); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 高陵县' - ); - }); - - it('should change select box display when expand item if set changeOnSelect to true', () => { - wrapper = mount(); - - const item00 = findItem(0, 0); - ReactTestUtils.Simulate.click(item00); - wrapper.update(); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西' - ); - - const item10 = findItem(1, 0); - ReactTestUtils.Simulate.click(item10); - wrapper.update(); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安' - ); - - const item20 = findItem(2, 0); - ReactTestUtils.Simulate.click(item20); - wrapper.update(); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 西安市' - ); - }); - - it('should render multiple cascader', () => { - const dataSource = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - }, - { - value: '2976', - label: '高陵县', - }, - ], - }, - { - value: '2980', - label: '铜川', - }, - ], - }, - ]; - let changeCalled = false; - const handleChange = (v, d, e) => { - assert.deepEqual(v, ['2980']); - assert.deepEqual(d, [ - { - value: '2980', - label: '铜川', - pos: '0-0-1', - }, - ]); - delete e.indeterminateData[0].children; - assert.deepEqual(e, { - checked: false, - currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, - checkedData: [{ value: '2980', label: '铜川', pos: '0-0-1' }], - indeterminateData: [{ value: '2973', label: '陕西', pos: '0-0' }], - }); - changeCalled = true; - }; - - wrapper = mount( - - ); - assert.deepEqual(getLabels(wrapper), ['铜川', '西安市']); - - const removeLink = wrapper.find('span.next-tag-close-btn').at(1); - removeLink.simulate('click'); - assert.deepEqual(getLabels(wrapper), ['铜川']); - assert(changeCalled); - - wrapper.setProps({ - displayRender: labelPath => labelPath.join(' / '), - }); - assert.deepEqual(getLabels(wrapper), ['陕西 / 铜川']); - }); - - it('should render multiple cascader when set checkStrictly to true', () => { - const dataSource = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - }, - { - value: '2976', - label: '高陵县', - }, - ], - }, - { - value: '2980', - label: '铜川', - }, - ], - }, - ]; - let changeCalled = false; - const handleChange = (v, d, e) => { - assert.deepEqual(v, ['2980']); - assert.deepEqual(d, [ - { - value: '2980', - label: '铜川', - pos: '0-0-1', - }, - ]); - assert.deepEqual(e, { - checked: false, - currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, - checkedData: [{ value: '2980', label: '铜川', pos: '0-0-1' }], - }); - changeCalled = true; - }; - const wrapper = mount( - - ); - assert.deepEqual(getLabels(wrapper), ['西安市', '铜川']); - - const removeLink = wrapper.find('span.next-tag-close-btn').at(0); - removeLink.simulate('click'); - assert.deepEqual(getLabels(wrapper), ['铜川']); - assert(changeCalled); - }); - - it('should support searching when it is a single cascader select', () => { - wrapper = mount(); - wrapper.find('.next-select-trigger-search input').simulate('change', { target: { value: '哈哈' } }); - wrapper.update(); - assert(document.querySelector('.next-cascader-select-not-found').textContent.trim() === '无选项'); - - wrapper.find('.next-select-trigger-search input').simulate('change', { target: { value: '高陵' } }); - wrapper.update(); - assert(document.querySelector('.next-cascader-filtered-list').textContent.trim() === '陕西 / 西安 / 高陵县'); - assert(document.querySelector('.next-cascader-filtered-list em').textContent.trim() === '高陵'); - - ReactTestUtils.Simulate.click(document.querySelector('.next-cascader-filtered-item')); - wrapper.update(); - assert( - wrapper - .find('span.next-select-inner em') - .text() - .trim() === '陕西 / 西安 / 高陵县' - ); - }); - - it('should support searching when it is a multiple cascader select', () => { - wrapper = mount( - - ); - wrapper.find('.next-select-trigger-search input').simulate('change', { target: { value: '哈哈' } }); - wrapper.update(); - assert(document.querySelector('.next-cascader-select-not-found').textContent.trim() === '无选项'); - - wrapper.find('.next-select-trigger-search input').simulate('change', { target: { value: '高陵' } }); - wrapper.update(); - assert(document.querySelector('.next-cascader-filtered-list').textContent.trim() === '陕西 / 西安 / 高陵县'); - assert(document.querySelector('.next-cascader-filtered-list em').textContent.trim() === '高陵'); - }); - - it('should ignore case when searching', () => { - const SpecialChars = '-[.+*?^$()[]{}|\\'; - const dataSource = [ - { - value: 'Aa', - label: 'Aa', - children: [ - { - value: 'Bb', - label: 'Bb', - }, - { - value: SpecialChars, - label: SpecialChars, - }, - ], - }, - ]; - wrapper = mount(); - - const specialCharCases = SpecialChars.split('').map(c => [c, c]); - - [['aa', 'Aa'], ['BB', 'Bb'], ...specialCharCases].forEach(([iptVal, excepted]) => { - wrapper.find('.next-select-trigger-search input').simulate('change', { target: { value: iptVal } }); - wrapper.update(); - assert(document.querySelector('.next-cascader-filtered-list em').textContent.trim() === excepted); - }); - }); - - it('should support keyborad', done => { - wrapper = mount(); - wrapper.find('.next-select').simulate('click'); - setTimeout(() => { - let cascader = document.querySelectorAll('.next-cascader'); - cascader = cascader[cascader.length - 1]; - assert(cascader); - wrapper.find('.next-select-trigger-search input').simulate('keydown', { keyCode: KEYCODE.DOWN }); - assert(document.activeElement === findRealItem(cascader, 0, 0)); - done(); - }, 2000); - }); - - it('should support signle value not in dataSource', () => { - const VALUE = '222333'; - let called = false; - const valueRender = item => { - assert(!item.label); - assert(item.value === VALUE); - called = true; - }; - wrapper = mount(); - assert(called); - }); - - it('should support multiple value not in dataSource', () => { - const VALUE = '222333'; - let called = false; - const valueRender = item => { - assert(!item.label); - assert(item.value === VALUE); - called = true; - }; - wrapper = mount( - item.label || ''} - dataSource={ChinaArea} - valueRender={valueRender} - /> - ); - wrapper.setProps({ - value: VALUE, - }); - assert(called); - wrapper.setProps({ - valueRender: item => item.label, - onChange: value => { - assert.deepEqual(value, [VALUE, '2973']); - }, - }); - const item00 = findItem(0, 0); - ReactTestUtils.Simulate.click(item00); - }); - - it('should support preview mode render', () => { - const dataSource = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - }, - { - value: '2976', - label: '高陵县', - }, - ], - }, - { - value: '2980', - label: '铜川', - }, - ], - }, - ]; - - wrapper = mount(); - assert(wrapper.find('.next-form-preview').length > 0); - assert(wrapper.find('.next-form-preview').text() === '陕西 / 西安 / 西安市'); - wrapper.setProps({ - renderPreview: items => { - assert(items.length === 1); - assert(items[0].label === '陕西 / 西安 / 西安市'); - return 'Hello World'; - }, - }); - assert(wrapper.find('.next-form-preview').text() === 'Hello World'); - }); - - it('should support setting resultAutoWidth to false', done => { - const width = '120px'; - const container = document.createElement('div'); - - document.body.appendChild(container); - - act(() => { - ReactDOM.render( - , - container - ); - }); - - const iptElem = document.querySelector('.cs-auto-width input'); - - ReactTestUtils.Simulate.input(iptElem); - iptElem.value = '杭州'; - ReactTestUtils.Simulate.change(iptElem); - - setTimeout(() => { - const popEl = document.querySelector('.result-auto-width-popup'); - - assert(popEl.style.width === ''); - - popEl.remove(); - container.remove(); - done(); - }, 50); - }); - - it('should support expandedValue', () => { - wrapper = mount( - - ); - assert(findRealItem(document.querySelector('.myCascaderSelect'), 2, 1)); - }); - - it('should support immutable data', () => { - wrapper = mount( - - ); - assert(findRealItem(document.querySelector('.myCascaderSelect'), 2, 1)); - }); - - it('should support onSearch', () => { - wrapper = mount( - assert(v === 'searchValue')} - defaultVisible - /> - ); - - wrapper.find('input').simulate('change', { target: { value: 'searchValue' } }); - }); - - it('keep value && label after dataSource updated', () => { - const newDataSource = [ - { - value: '3478', - label: '浙江', - children: [ - { - value: '3479', - label: '杭州', - children: [{ value: '3480', label: '杭州市' }, { value: '3481', label: '建德市' }], - }, - ], - }, - ]; - - // 多选 multiple=true - wrapper = mount(); - - wrapper.setProps({ - dataSource: newDataSource, - }); - assert.deepEqual(getLabels(wrapper), ['西安市']); - - wrapper - .find('.next-checkbox-input') - .at(0) - .simulate('change', { target: { checked: true } }); - - assert.deepEqual(getLabels(wrapper), ['西安市', '浙江']); - - wrapper - .find('.next-tag-close-btn') - .at(0) - .simulate('click'); - - assert.deepEqual(getLabels(wrapper), ['浙江']); - wrapper.unmount(); - - // 单选 multiple=false - wrapper = mount(); - - assert(wrapper.find('.next-input-text-field em').text() === '陕西 / 西安 / 西安市'); - wrapper.setProps({ - dataSource: newDataSource, - }); - assert(wrapper.find('.next-input-text-field em').text() === '陕西 / 西安 / 西安市'); - }); - - it('should support popup v2', async () => { - wrapper = mount(); - wrapper.find('.next-select').simulate('click'); - await delay(300); - assert(document.querySelector('.next-cascader-select-dropdown')); - }); -}); - -function findItem(menuIndex, itemIndex) { - return document.querySelectorAll('.next-cascader-menu')[menuIndex].children[itemIndex]; -} - -function getLabels(wrapper) { - return wrapper.find('span.next-tag-body').map(node => node.text().trim()); -} - -function findRealItem(cascader, listIndex, itemIndex) { - return cascader.querySelectorAll('.next-cascader-menu')[listIndex].querySelectorAll('.next-cascader-menu-item')[ - itemIndex - ]; -} diff --git a/components/cascader-select/__tests__/index-spec.tsx b/components/cascader-select/__tests__/index-spec.tsx new file mode 100644 index 0000000000..0ace606759 --- /dev/null +++ b/components/cascader-select/__tests__/index-spec.tsx @@ -0,0 +1,569 @@ +import React, { useState } from 'react'; +import CascaderSelect, { type CascaderSelectDataItem, type CascaderSelectProps } from '../index'; +import '../style'; + +function freeze(dataSource: NonNullable) { + return dataSource.map(item => { + const { children } = item; + children && freeze(children); + return Object.freeze({ ...item }); + }); +} + +function findItem(menuIndex: number, itemIndex: number) { + return cy.get('.next-cascader-menu').eq(menuIndex).children().eq(itemIndex); +} + +function labelsShouldBe(expected: string[]) { + cy.get('span.next-tag-body').should('have.text', expected.join('')); +} + +function findRealItem( + cascader: Cypress.Chainable>, + listIndex: number, + itemIndex: number +) { + return cascader + .find('.next-cascader-menu') + .eq(listIndex) + .find('.next-cascader-menu-item') + .eq(itemIndex); +} + +const ChinaArea = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3078', + label: '四川', + }, +]; + +describe('CascaderSelect', () => { + it('should show dropdown when set defaultVisible to true', () => { + cy.mount(); + cy.get('.next-cascader-select-dropdown').should('exist'); + }); + + it('should show dropdown when click select box', () => { + cy.mount(); + cy.get('.next-cascader-select-dropdown').should('not.exist'); + cy.get('.next-select').click(); + cy.get('.next-cascader-select-dropdown').should('exist'); + }); + + it('should render single cascader select', () => { + const handleChange = cy.spy(); + + cy.mount( + + ).as('Demo'); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 西安市'); + + findItem(2, 1).click(); + cy.wrap(handleChange).should('be.called'); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 高陵县'); + cy.rerender('Demo', { displayRender: (label: string[]) => label.join('-') }); + cy.get('.next-select-inner em').should('have.text', '陕西-西安-高陵县'); + }); + + it('should render single cascader under control', () => { + const changedSpy = cy.spy(); + const Demo = () => { + const [value, setValue] = useState('2975'); + const handleChange: CascaderSelectProps['onChange'] = (value: string) => { + changedSpy(value); + setValue(value); + }; + return ( + + ); + }; + + cy.mount(); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 西安市'); + findItem(2, 1).click(); + cy.wrap(changedSpy).should('be.called'); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 高陵县'); + }); + + it('should change select box display when expand item if set changeOnSelect to true', () => { + cy.mount(); + findItem(0, 0).click(); + cy.get('.next-select-inner em').should('have.text', '陕西'); + findItem(1, 0).click(); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安'); + findItem(2, 0).click(); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 西安市'); + }); + + it('should render multiple cascader', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + }, + { + value: '2976', + label: '高陵县', + }, + ], + }, + { + value: '2980', + label: '铜川', + }, + ], + }, + ]; + const spyChange = cy.spy().as('handleChange'); + const handleChange: CascaderSelectProps['onChange'] = (v, d, e) => { + spyChange(); + expect(v).to.deep.equal(['2980']); + expect(d).to.deep.equal([ + { + value: '2980', + label: '铜川', + pos: '0-0-1', + }, + ]); + delete e!.indeterminateData![0].children; + expect(e).deep.equal({ + checked: false, + currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, + checkedData: [{ value: '2980', label: '铜川', pos: '0-0-1' }], + indeterminateData: [{ value: '2973', label: '陕西', pos: '0-0' }], + }); + }; + + cy.mount( + + ); + labelsShouldBe(['铜川', '西安市']); + cy.get('span.next-tag-close-btn').eq(1).click(); + labelsShouldBe(['铜川']); + cy.get('@handleChange').should('be.called'); + }); + + it('should render multiple cascader when set checkStrictly to true', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + }, + { + value: '2976', + label: '高陵县', + }, + ], + }, + { + value: '2980', + label: '铜川', + }, + ], + }, + ]; + const spyChange = cy.spy().as('handleChange'); + const handleChange: CascaderSelectProps['onChange'] = (v, d, e) => { + spyChange(); + expect(v).to.deep.equal(['2980']); + expect(d).to.deep.equal([ + { + value: '2980', + label: '铜川', + pos: '0-0-1', + }, + ]); + expect(e).to.deep.equal({ + checked: false, + currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, + checkedData: [{ value: '2980', label: '铜川', pos: '0-0-1' }], + }); + }; + cy.mount( + + ); + labelsShouldBe(['西安市', '铜川']); + cy.get('span.next-tag-close-btn').eq(0).click(); + labelsShouldBe(['铜川']); + cy.get('@handleChange').should('be.called'); + }); + + it('should support searching when it is a single cascader select', () => { + cy.mount( + + ); + cy.get('.next-select-trigger-search input').type('哈哈'); + cy.get('.next-cascader-select-not-found').should('have.text', '无选项'); + cy.get('.next-select-trigger-search input').clear(); + cy.get('.next-select-trigger-search input').type('高陵'); + cy.get('.next-cascader-filtered-list').should('have.text', '陕西 / 西安 / 高陵县'); + cy.get('.next-cascader-filtered-list em').should('have.text', '高陵'); + cy.get('.next-cascader-filtered-item').click(); + cy.get('.next-select-inner em').should('have.text', '陕西 / 西安 / 高陵县'); + }); + + it('should support searching when it is a multiple cascader select', () => { + cy.mount( + + ); + cy.get('.next-select-trigger-search input').type('哈哈'); + cy.get('.next-cascader-select-not-found').should('have.text', '无选项'); + cy.get('.next-select-trigger-search input').clear(); + cy.get('.next-select-trigger-search input').type('高陵'); + cy.get('.next-cascader-filtered-list').should('have.text', '陕西 / 西安 / 高陵县'); + cy.get('.next-cascader-filtered-list em').should('have.text', '高陵'); + }); + + it('should ignore case when searching', () => { + const SpecialChars = '-[.+*?^$()[]{}|\\'; + const dataSource = [ + { + value: 'Aa', + label: 'Aa', + children: [ + { + value: 'Bb', + label: 'Bb', + }, + { + value: SpecialChars, + label: SpecialChars, + }, + ], + }, + ]; + cy.mount( + + ); + + const specialCharCases = SpecialChars.split('').map(c => [c, c]); + + [['aa', 'Aa'], ['BB', 'Bb'], ...specialCharCases].forEach(([iptVal, excepted]) => { + cy.get('.next-select-trigger-search input').type(iptVal); + cy.get('.next-cascader-filtered-list em').eq(0).should('have.text', excepted); + cy.get('.next-select-trigger-search input').clear(); + }); + }); + + it('should support keyboard', () => { + cy.mount(); + cy.get('.next-select').click(); + cy.get('.next-cascader').should('exist'); + cy.get('.next-select-trigger-search input').type('{downArrow}', { force: true }); + findRealItem(cy.get('.next-cascader'), 0, 0).then($el => { + expect($el.get(0)).to.equal(document.activeElement); + }); + }); + + it('should support signle value not in dataSource', () => { + const VALUE = '222333'; + const handleValueRender = cy.spy().as('handleValueRender'); + const valueRender: CascaderSelectProps['valueRender'] = item => { + handleValueRender(!item.label, item.value); + }; + cy.mount( + + ); + cy.get('@handleValueRender').should('be.calledWith', true, VALUE); + }); + + it('should support multiple value not in dataSource', () => { + const VALUE = '222333'; + const handleValueRender = cy.spy().as('handleValueRender'); + const valueRender: CascaderSelectProps['valueRender'] = item => { + handleValueRender(!item.label, item.value); + }; + cy.mount( + item.label || ''} + dataSource={ChinaArea} + valueRender={valueRender} + defaultVisible + /> + ).as('Demo'); + cy.rerender('Demo', { value: VALUE }); + cy.get('@handleValueRender').should('be.calledWith', true, VALUE); + const handleChange = cy.spy(); + cy.rerender('Demo', { + valueRender: item => item.label, + onChange: handleChange, + }); + findItem(0, 0).find('input').check(); + cy.wrap(handleChange).should('be.calledWith', [VALUE, '2973']); + }); + + it('should support preview mode render', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + }, + { + value: '2976', + label: '高陵县', + }, + ], + }, + { + value: '2980', + label: '铜川', + }, + ], + }, + ]; + + cy.mount().as( + 'Demo' + ); + cy.get('.next-form-preview').should('exist'); + cy.get('.next-form-preview').should('have.text', '陕西 / 西安 / 西安市'); + cy.rerender('Demo', { + renderPreview: (items: CascaderSelectDataItem[]) => { + expect(items.length).to.equal(1); + expect(items[0].label).to.equal('陕西 / 西安 / 西安市'); + return 'Hello World'; + }, + }); + cy.get('.next-form-preview').should('have.text', 'Hello World'); + }); + + it('should support setting resultAutoWidth to false', () => { + const width = '120px'; + cy.mount( + + ); + cy.get('.cs-auto-width input').type('杭州'); + cy.get('.result-auto-width-popup').then($el => { + expect($el.get(0).style.width).to.equal(''); + }); + }); + + it('should support expandedValue', () => { + cy.mount( + + ); + findRealItem(cy.get('.myCascaderSelect'), 2, 1).should('exist'); + }); + + it('should support immutable data', () => { + cy.mount( + + ); + findRealItem(cy.get('.myCascaderSelect'), 2, 1).should('exist'); + }); + + it('should support onSearch', () => { + const handleSearch = cy.spy(); + cy.mount( + + ); + cy.get('input').type('searchValue'); + cy.wrap(handleSearch).should('be.calledWith', 'searchValue'); + }); + + it('keep value && label after dataSource updated', () => { + const newDataSource = [ + { + value: '3478', + label: '浙江', + children: [ + { + value: '3479', + label: '杭州', + children: [ + { value: '3480', label: '杭州市' }, + { value: '3481', label: '建德市' }, + ], + }, + ], + }, + ]; + + // 多选 multiple=true + cy.mount( + + ).as('Demo'); + + cy.rerender('Demo', { dataSource: newDataSource }); + + labelsShouldBe(['西安市']); + + cy.get('.next-checkbox-input').eq(0).check(); + labelsShouldBe(['西安市', '浙江']); + + cy.get('.next-tag-close-btn').eq(0).click(); + labelsShouldBe(['浙江']); + + // // 单选 multiple=false + cy.mount().as('Demo1'); + + cy.get('.next-input-text-field em').should('have.text', '陕西 / 西安 / 西安市'); + + cy.rerender('Demo1', { dataSource: newDataSource }); + + cy.get('.next-input-text-field em').should('have.text', '陕西 / 西安 / 西安市'); + }); + + it('should support popup v2', () => { + cy.mount(); + cy.get('.next-select').click(); + cy.get('.next-cascader-select-dropdown').should('exist'); + }); + + it('should support focus api', () => { + let cs: InstanceType | null = null; + cy.mount( + { + cs = c; + }} + dataSource={ChinaArea} + /> + ).as('Demo'); + cy.then(() => { + cs?.getInstance().focus(); + expect(document.activeElement!.id).to.equal('cascader-focus'); + }); + }); + + it('should support visible by keyboard', () => { + cy.mount(); + cy.get('input').type('{upArrow}', { force: true }); + cy.get('.next-cascader-select-dropdown').should('exist'); + }); + + it('should support empty search value after selection , close #3008', () => { + const handleChange = cy.spy(); + cy.mount( + + ); + cy.get('.next-select-trigger-search input').type('西安'); + cy.get('.next-cascader-filtered-list').should('have.length', 1); + cy.get('.next-menu-item').first().click(); + cy.get('.next-cascader-filtered-list').should('have.length', 0); + cy.get('.next-cascader > .next-cascader-inner').should('not.be.empty'); + cy.get('.next-tag').invoke('text').should('eq', '西安'); + cy.get('.next-select-trigger-search input').should('have.text', ''); + }); + + it('should support The value of the menuProps attribute is passed by props, close #3852', () => { + cy.mount( + + ); + cy.get('.next-select').click(); + cy.get('.next-menu-item').first().should('have.class', 'test-list-cls'); + }); +}); diff --git a/components/cascader-select/__tests__/issue-spec.js b/components/cascader-select/__tests__/issue-spec.js deleted file mode 100644 index 7f0c2ba6cc..0000000000 --- a/components/cascader-select/__tests__/issue-spec.js +++ /dev/null @@ -1,125 +0,0 @@ -import React, { createRef, useState } from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import CascaderSelect from '../index'; -import '../style'; - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it beforeEach */ -/* global describe it afterEach */ - -Enzyme.configure({ adapter: new Adapter() }); - -function delay(duration) { - return new Promise(resolve => setTimeout(resolve, duration)); -} - -const ChinaAreaData = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [{ value: '2975', label: '西安市' }, { value: '2976', label: '高陵县' }], - }, - { - value: '2980', - label: '铜川', - children: [{ value: '2981', label: '铜川市' }, { value: '2982', label: '宜君县' }], - }, - ], - }, - { - value: '3078', - label: '四川', - }, - { - children: [ - { - value: '3372', - label: '乌鲁木齐', - children: [ - { - value: '3373', - label: '乌鲁木齐市', - }, - { - value: '3374', - label: '乌鲁木齐县', - }, - ], - }, - ], - value: '3371', - label: '新疆', - }, -]; - -describe('CascaderSelect issues', function() { - this.timeout(1000000); - let wrapper, root; - beforeEach(() => { - root = document.createElement('div'); - document.body.appendChild(root); - }); - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should sync expandedValue when visible=false and props.value changed ', async () => { - const ref = createRef(); - - function Demo() { - const [value, setValue] = useState('2975'); - const [visible, setVisible] = useState(false); - ref.current = { setValue, setVisible }; - return ( - setValue(v)} - /> - ); - } - wrapper = mount(, { - attachTo: root, - }); - assert(root.querySelector('span.next-select-inner em').textContent.trim() === '陕西 / 西安 / 西安市'); - ref.current.setVisible(true); - await delay(100); - assert(isExpanded('陕西', 0, 0, root)); - assert(isExpanded('西安', 1, 0, root)); - assert(isSelected('西安市', 2, 0, root)); - ref.current.setVisible(false); - await delay(500); - ref.current.setValue('3373'); - assert(root.querySelector('span.next-select-inner em').textContent.trim() === '新疆 / 乌鲁木齐 / 乌鲁木齐市'); - ref.current.setVisible(true); - await delay(100); - assert(isExpanded('新疆', 0, 2, root)); - assert(isExpanded('乌鲁木齐', 1, 0, root)); - assert(isSelected('乌鲁木齐市', 2, 0, root)); - }); -}); - -function findItem(menuIndex, itemIndex, root = document) { - return root.querySelectorAll('.next-cascader-menu')[menuIndex].children[itemIndex]; -} - -function isExpanded(text, menuIndex, itemIndex, root = document) { - const item = findItem(menuIndex, itemIndex, root); - return !!item && item.textContent.trim() === text && item.classList.contains('next-expanded'); -} - -function isSelected(text, menuIndex, itemIndex, root = document) { - const item = findItem(menuIndex, itemIndex, root); - return !!item && item.textContent.trim() === text && item.classList.contains('next-selected'); -} diff --git a/components/cascader-select/__tests__/issue-spec.tsx b/components/cascader-select/__tests__/issue-spec.tsx new file mode 100644 index 0000000000..65b1428489 --- /dev/null +++ b/components/cascader-select/__tests__/issue-spec.tsx @@ -0,0 +1,122 @@ +import React, { forwardRef, useEffect, useState } from 'react'; +import CascaderSelect from '../index'; +import '../style'; + +const ChinaAreaData = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3078', + label: '四川', + }, + { + children: [ + { + value: '3372', + label: '乌鲁木齐', + children: [ + { + value: '3373', + label: '乌鲁木齐市', + }, + { + value: '3374', + label: '乌鲁木齐县', + }, + ], + }, + ], + value: '3371', + label: '新疆', + }, +]; + +function findItem(menuIndex: number, itemIndex: number) { + return cy.get('.next-cascader-menu').eq(menuIndex).children().eq(itemIndex); +} + +function shouldExpanded(text: string, menuIndex: number, itemIndex: number) { + const item = findItem(menuIndex, itemIndex); + item.should('have.text', text); + item.should('have.class', 'next-expanded'); +} + +function shouldSelected(text: string, menuIndex: number, itemIndex: number) { + const item = findItem(menuIndex, itemIndex); + item.should('exist'); + item.should('have.text', text); + item.should('have.class', 'next-selected'); +} + +describe('CascaderSelect issues', function () { + it('should sync expandedValue when visible=false and props.value changed ', () => { + const Demo = forwardRef(props => { + const { value: propsValue = '2975', visible = false } = props; + const [value, setValue] = useState(propsValue); + useEffect(() => { + setValue(propsValue); + }, [propsValue]); + return ( + setValue(v)} + /> + ); + }); + cy.mount().as('Demo'); + cy.get('span.next-select-inner em').should('have.text', '陕西 / 西安 / 西安市'); + cy.rerender('Demo', { visible: true }); + shouldExpanded('陕西', 0, 0); + shouldExpanded('西安', 1, 0); + shouldSelected('西安市', 2, 0); + cy.rerender('Demo', { value: '3373', visible: false }).as('Demo1'); + cy.get('span.next-select-inner em').should('have.text', '新疆 / 乌鲁木齐 / 乌鲁木齐市'); + cy.get('.next-cascader-menu').should('not.exist'); + cy.rerender('Demo', { value: '3373', visible: true }); + shouldExpanded('新疆', 0, 2); + shouldExpanded('乌鲁木齐', 1, 0); + shouldSelected('乌鲁木齐市', 2, 0); + }); + + // Fix https://github.com/alibaba-fusion/next/issues/3704 + it('When using virtual scrolling, the background color should be white', () => { + cy.mount( +
    + +
    + ); + cy.get('.next-cascader-menu-wrapper').should( + 'have.css', + 'background-color', + 'rgb(255, 255, 255)' + ); + }); +}); diff --git a/components/cascader-select/cascader-select.jsx b/components/cascader-select/cascader-select.jsx deleted file mode 100644 index 1e97f238e5..0000000000 --- a/components/cascader-select/cascader-select.jsx +++ /dev/null @@ -1,1000 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import classNames from 'classnames'; -import Select from '../select'; -import Cascader from '../cascader'; -import Menu from '../menu'; -import { func, obj, dom, KEYCODE } from '../util'; -import zhCN from '../locale/zh-cn'; - -const { bindCtx } = func; -const { pickOthers } = obj; -const { getStyle } = dom; - -const normalizeValue = value => { - if (value) { - if (Array.isArray(value)) { - return value; - } - - return [value]; - } - - return []; -}; - -/** - * CascaderSelect - */ -class CascaderSelect extends Component { - static propTypes = { - prefix: PropTypes.string, - pure: PropTypes.bool, - className: PropTypes.string, - /** - * 选择框大小 - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * 选择框占位符 - */ - placeholder: PropTypes.string, - /** - * 是否禁用 - */ - disabled: PropTypes.bool, - /** - * 是否有下拉箭头 - */ - hasArrow: PropTypes.bool, - /** - * 是否有边框 - */ - hasBorder: PropTypes.bool, - /** - * 是否有清除按钮 - */ - hasClear: PropTypes.bool, - /** - * 自定义内联 label - */ - label: PropTypes.node, - /** - * 是否只读,只读模式下可以展开弹层但不能选 - */ - readOnly: PropTypes.bool, - /** - * 数据源,结构可参考下方说明 - */ - dataSource: PropTypes.arrayOf(PropTypes.object), - /** - * (非受控)默认值 - */ - defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - /** - * (受控)当前值 - */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - /** - * 选中值改变时触发的回调函数 - * @param {String|Array} value 选中的值,单选时返回单个值,多选时返回数组 - * @param {Object|Array} data 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点 - * @param {Object} extra 额外参数 - * @param {Array} extra.selectedPath 单选时选中的数据的路径 - * @param {Boolean} extra.checked 多选时当前的操作是选中还是取消选中 - * @param {Object} extra.currentData 多选时当前操作的数据 - * @param {Array} extra.checkedData 多选时所有被选中的数据 - * @param {Array} extra.indeterminateData 多选时半选的数据 - */ - onChange: PropTypes.func, - /** - * 默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 - */ - defaultExpandedValue: PropTypes.arrayOf(PropTypes.string), - /** - * (受控)当前展开值 - */ - expandedValue: PropTypes.arrayOf(PropTypes.string), - /** - * 展开触发的方式 - */ - expandTriggerType: PropTypes.oneOf(['click', 'hover']), - onExpand: PropTypes.func, - /** - * 是否开启虚拟滚动 - */ - useVirtual: PropTypes.bool, - /** - * 是否多选 - */ - multiple: PropTypes.bool, - /** - * 是否选中即发生改变, 该属性仅在单选模式下有效 - */ - changeOnSelect: PropTypes.bool, - /** - * 是否只能勾选叶子项的checkbox,该属性仅在多选模式下有效 - */ - canOnlyCheckLeaf: PropTypes.bool, - /** - * 父子节点是否选中不关联 - */ - checkStrictly: PropTypes.bool, - /** - * 每列列表样式对象 - */ - listStyle: PropTypes.object, - /** - * 每列列表类名 - */ - listClassName: PropTypes.string, - /** - * 选择框单选时展示结果的自定义渲染函数 - * @param {Array} label 选中路径的文本数组 - * @return {ReactNode} 渲染在选择框中的内容 - * @default 单选时:labelPath => labelPath.join(' / ');多选时:labelPath => labelPath[labelPath.length - 1] - */ - displayRender: PropTypes.func, - /** - * 渲染 item 内容的方法 - * @param {Object} item 渲染节点的item - * @return {ReactNode} item node - */ - itemRender: PropTypes.func, - /** - * 是否显示搜索框 - */ - showSearch: PropTypes.bool, - /** - * 自定义搜索函数 - * @param {String} searchValue 搜索的关键字 - * @param {Array} path 节点路径 - * @return {Boolean} 是否匹配 - * @default 根据路径所有节点的文本值模糊匹配 - */ - filter: PropTypes.func, - /** - * 当搜索框值变化时回调 - * @param {String} value 数据 - * @version 1.23 - */ - onSearch: PropTypes.func, - /** - * 搜索结果自定义渲染函数 - * @param {String} searchValue 搜索的关键字 - * @param {Array} path 匹配到的节点路径 - * @return {ReactNode} 渲染的内容 - * @default 按照节点文本 a / b / c 的模式渲染 - */ - resultRender: PropTypes.func, - /** - * 搜索结果列表是否和选择框等宽 - */ - resultAutoWidth: PropTypes.bool, - /** - * 无数据时显示内容 - */ - notFoundContent: PropTypes.node, - /** - * 国际化 - */ - locale: PropTypes.object, - /** - * 异步加载数据函数 - * @param {Object} data 当前点击异步加载的数据 - */ - loadData: PropTypes.func, - /** - * 自定义下拉框头部 - */ - header: PropTypes.node, - /** - * 自定义下拉框底部 - */ - footer: PropTypes.node, - /** - * 初始下拉框是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 当前下拉框是否显示 - */ - visible: PropTypes.bool, - /** - * 下拉框显示或关闭时触发事件的回调函数 - * @param {Boolean} visible 是否显示 - * @param {String} type 触发显示关闭的操作类型, fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 下拉框自定义样式对象 - */ - popupStyle: PropTypes.object, - /** - * 下拉框样式自定义类名 - */ - popupClassName: PropTypes.string, - /** - * 下拉框挂载的容器节点 - */ - popupContainer: PropTypes.any, - /** - * 透传到 Popup 的属性对象 - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 是否为预览态 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {Array} value 选择值 { label: , value:} - */ - renderPreview: PropTypes.func, - /** - * 是否是不可变数据 - * @version 1.23 - */ - immutable: PropTypes.bool, - }; - - static defaultProps = { - prefix: 'next-', - pure: false, - size: 'medium', - disabled: false, - hasArrow: true, - hasBorder: true, - hasClear: false, - dataSource: [], - defaultValue: null, - expandTriggerType: 'click', - onExpand: () => {}, - useVirtual: false, - multiple: false, - changeOnSelect: false, - canOnlyCheckLeaf: false, - checkStrictly: false, - showSearch: false, - filter: (searchValue, path) => { - return path.some( - item => - String(item.label) - .toLowerCase() - .indexOf(String(searchValue).toLowerCase()) > -1 - ); - }, - resultRender: (searchValue, path) => { - const parts = []; - path.forEach((item, i) => { - const reExp = searchValue.replace(/[-.+*?^$()[\]{}|\\]/g, v => `\\${v}`); - - const re = new RegExp(reExp, 'gi'); - const others = item.label.split(re); - const matches = item.label.match(re); - - others.forEach((other, j) => { - if (other) { - parts.push(other); - } - if (j < others.length - 1) { - parts.push({matches[j]}); - } - }); - if (i < path.length - 1) { - parts.push(' / '); - } - }); - return {parts}; - }, - resultAutoWidth: true, - defaultVisible: false, - onVisibleChange: () => {}, - popupProps: {}, - immutable: false, - locale: zhCN.Select, - }; - - constructor(props) { - super(props); - - this.state = { - value: normalizeValue('value' in props ? props.value : props.defaultValue), - searchValue: '', - visible: typeof props.visible === 'undefined' ? props.defaultVisible : props.visible, - }; - - // 缓存选中值数据 - this._valueDataCache = {}; - - bindCtx(this, [ - 'handleVisibleChange', - 'handleAfterOpen', - 'handleSelect', - 'handleChange', - 'handleClear', - 'handleRemove', - 'handleSearch', - 'getPopup', - 'saveSelectRef', - 'saveCascaderRef', - 'handleKeyDown', - ]); - } - - static getDerivedStateFromProps(props) { - const st = {}; - - if ('value' in props) { - st.value = normalizeValue(props.value); - } - if ('visible' in props) { - st.visible = props.visible; - } - - return st; - } - - updateCache(dataSource) { - this._v2n = {}; - this._p2n = {}; - const loop = (data, prefix = '0') => - data.forEach((item, index) => { - const { value, children } = item; - const pos = `${prefix}-${index}`; - this._v2n[value] = this._p2n[pos] = { ...item, pos }; - - if (children && children.length) { - loop(children, pos); - } - }); - - loop(dataSource); - } - - flatValue(value) { - const getDepth = v => { - const pos = this.getPos(v); - if (!pos) { - return 0; - } - return pos.split('-').length; - }; - const newValue = value.slice(0).sort((prev, next) => { - return getDepth(prev) - getDepth(next); - }); - - for (let i = 0; i < newValue.length; i++) { - for (let j = 0; j < newValue.length; j++) { - if (i !== j && this.isDescendantOrSelf(this.getPos(newValue[i]), this.getPos(newValue[j]))) { - newValue.splice(j, 1); - j--; - } - } - } - - return newValue; - } - - isDescendantOrSelf(currentPos, targetPos) { - if (!currentPos || !targetPos) { - return false; - } - - const currentNums = currentPos.split('-'); - const targetNums = targetPos.split('-'); - - return ( - currentNums.length <= targetNums.length && - currentNums.every((num, index) => { - return num === targetNums[index]; - }) - ); - } - - getValue(pos) { - return this._p2n[pos] ? this._p2n[pos].value : null; - } - - getPos(value) { - return this._v2n[value] ? this._v2n[value].pos : null; - } - - getData(value) { - return value.map(v => this._v2n[v] || this._valueDataCache[v]); - } - - getLabelPath(data) { - const nums = data.pos.split('-'); - return nums.slice(1).reduce((ret, num, index) => { - const p = nums.slice(0, index + 2).join('-'); - ret.push(this._p2n[p].label); - return ret; - }, []); - } - - getSingleData(value) { - if (!value.length) { - return null; - } - - if (Array.isArray(value)) value = value[0]; - - let data = this._v2n[value]; - - if (data) { - const labelPath = this.getLabelPath(data); - const displayRender = this.props.displayRender || (labels => labels.join(' / ')); - - data = { - ...data, - label: displayRender(labelPath, data), - }; - - this._valueDataCache[value] = data; - this.refreshValueDataCache(value); - } else { - data = this._valueDataCache[value]; - } - - return ( - data || { - value, - } - ); - } - - getMultipleData(value) { - if (!value.length) { - return null; - } - - const { checkStrictly, canOnlyCheckLeaf, displayRender } = this.props; - const flatValue = checkStrictly || canOnlyCheckLeaf ? value : this.flatValue(value); - let data = flatValue.map(v => { - let item = this._v2n[v]; - - if (item) { - this._valueDataCache[v] = item; - } else { - item = this._valueDataCache[v]; - } - - return item || { value: v }; - }); - - if (displayRender) { - data = data.map(item => { - if (!item.pos || !(item.value in this._v2n)) { - return item; - } - - const labelPath = this.getLabelPath(item); - const newItem = { - ...item, - label: displayRender(labelPath, item), - }; - - this._valueDataCache[item.value] = newItem; - - return newItem; - }); - } - - return data; - } - - getIndeterminate(value) { - const indeterminate = []; - - const positions = value.map(this.getPos.bind(this)); - positions.forEach(pos => { - if (!pos) { - return false; - } - const nums = pos.split('-'); - for (let i = nums.length; i > 2; i--) { - const parentPos = nums.slice(0, i - 1).join('-'); - const parentValue = this.getValue(parentPos); - if (indeterminate.indexOf(parentValue) === -1) { - indeterminate.push(parentValue); - } - } - }); - - return indeterminate; - } - - saveSelectRef(ref) { - this.select = ref; - } - - saveCascaderRef(ref) { - this.cascader = ref; - } - - completeValue(value) { - const newValue = []; - - const flatValue = this.flatValue(value).reverse(); - const ps = Object.keys(this._p2n); - for (let i = 0; i < ps.length; i++) { - for (let j = 0; j < flatValue.length; j++) { - const v = flatValue[j]; - if (this.isDescendantOrSelf(this.getPos(v), ps[i])) { - newValue.push(this.getValue(ps[i])); - ps.splice(i, 1); - i--; - break; - } - } - } - - return newValue; - } - - isLeaf(data) { - return !((data.children && data.children.length) || (!!this.props.loadData && !data.isLeaf)); - } - - handleVisibleChange(visible, type) { - const { searchValue } = this.state; - if (!('visible' in this.props)) { - this.setState({ - visible, - }); - } - - if (!visible && searchValue) { - this.setState({ - searchValue: '', - }); - } - - if (['fromCascader', 'keyboard'].indexOf(type) !== -1 && !visible) { - // 这里需要延迟下,showSearch 的情况下通过手动设置 menuProps={{focusable: true}} 回车 focus 会有延迟 - setTimeout(() => this.select.focusInput(), 0); - } - - this.props.onVisibleChange(visible, type); - } - - handleKeyDown(e) { - const { onKeyDown } = this.props; - const { visible } = this.state; - - if (onKeyDown) { - onKeyDown(e); - } - - if (!visible) { - return; - } - - switch (e.keyCode) { - case KEYCODE.UP: - case KEYCODE.DOWN: - this.cascader.setFocusValue(); - e.preventDefault(); - break; - default: - break; - } - } - - getPopup(ref) { - this.popup = ref; - if (typeof this.props.popupProps.ref === 'function') { - this.props.popupProps.ref(ref); - } - } - - handleAfterOpen() { - if (!this.popup) { - return; - } - - const { prefix, popupProps } = this.props; - const { v2 = false } = popupProps; - if (!v2) { - const dropDownNode = this.popup - .getInstance() - .overlay.getInstance() - .getContentNode(); - const cascaderNode = dropDownNode.querySelector(`.${prefix}cascader`); - if (cascaderNode) { - this.cascaderHeight = getStyle(cascaderNode, 'height'); - } - } - - if (typeof popupProps.afterOpen === 'function') { - popupProps.afterOpen(); - } - } - - handleSelect(value, data) { - const { multiple, changeOnSelect } = this.props; - const { visible, searchValue } = this.state; - - if (!multiple && (!changeOnSelect || this.isLeaf(data) || !!searchValue)) { - this.handleVisibleChange(!visible, 'fromCascader'); - } - } - - /** - * 刷新值数据缓存,删除无效值 - * @param {Arrary | String} curValue 当前值 - */ - refreshValueDataCache = curValue => { - if (curValue) { - const valueArr = Array.isArray(curValue) ? curValue : [curValue]; - - valueArr.length && - Object.keys(this._valueDataCache).forEach(v => { - if (!valueArr.includes(v)) { - delete this._valueDataCache[v]; - } - }); - } else { - this._valueDataCache = {}; - } - }; - - handleChange(value, data, extra) { - const { multiple, onChange } = this.props; - const { searchValue, value: stateValue } = this.state; - - const st = {}; - - if (multiple && stateValue && Array.isArray(stateValue)) { - const noExistedValues = stateValue.filter(v => !this._v2n[v]); - - if (noExistedValues.length > 0) { - value = value.filter(v => { - return !(noExistedValues.indexOf(v) >= 0); - }); - } - - value = [...noExistedValues, ...value]; - // onChange 中的 data 参数也应该保留不存在的 value 的数据 - // 在 dataSource 异步加载的情况下,会出现value重复的现象,需要去重 - data = [...noExistedValues.map(v => this._valueDataCache[v]).filter(v => v), ...data].filter( - (current, index, arr) => { - return index === arr.indexOf(current); - } - ); - // 更新缓存 - this.refreshValueDataCache(value); - } - - if (!('value' in this.props)) { - st.value = value; - } - if (!multiple && searchValue) { - st.searchValue = ''; - } - if (Object.keys(st).length) { - this.setState(st); - } - - if (onChange) { - onChange(value, data, extra); - } - - if (searchValue && this.select) { - this.select.handleSearchClear(); - } - } - - handleClear() { - // 单选时点击清空按钮 - const { hasClear, multiple, treeCheckable } = this.props; - if (hasClear && (!multiple || !treeCheckable)) { - if (!('value' in this.props)) { - this.setState({ - value: [], - }); - } - - this.props.onChange(null, null); - } - } - - handleRemove(currentData) { - const { value: currentValue } = currentData; - let value; - - const { multiple, checkStrictly, onChange } = this.props; - if (multiple) { - value = [...this.state.value]; - value.splice(value.indexOf(currentValue), 1); - - if (this.props.onChange) { - const data = this.getData(value); - const checked = false; - - if (checkStrictly) { - this.props.onChange(value, data, { - checked, - currentData, - checkedData: data, - }); - } else { - const checkedValue = this.completeValue(value); - const checkedData = this.getData(checkedValue); - const indeterminateValue = this.getIndeterminate(value); - const indeterminateData = this.getData(indeterminateValue); - this.props.onChange(value, data, { - checked, - currentData, - checkedData, - indeterminateData, - }); - } - } - } else { - value = []; - onChange(null, null); - } - - if (!('value' in this.props)) { - this.setState({ - value, - }); - } - - this.refreshValueDataCache(value); - } - - handleSearch(searchValue) { - this.setState({ - searchValue, - }); - - this.props.onSearch && this.props.onSearch(searchValue); - } - - getPath(pos) { - const items = []; - - const nums = pos.split('-'); - if (nums === 2) { - items.push(this._p2n[pos]); - } else { - for (let i = 1; i < nums.length; i++) { - const p = nums.slice(0, i + 1).join('-'); - items.push(this._p2n[p]); - } - } - - return items; - } - - filterItems() { - const { multiple, changeOnSelect, canOnlyCheckLeaf, filter } = this.props; - const { searchValue } = this.state; - let items = Object.keys(this._p2n).map(p => this._p2n[p]); - if ((!multiple && !changeOnSelect) || (multiple && canOnlyCheckLeaf)) { - items = items.filter(item => !item.children || !item.children.length); - } - - return items.map(item => this.getPath(item.pos)).filter(path => filter(searchValue, path)); - } - - renderNotFound() { - const { prefix, notFoundContent, locale } = this.props; - return ( - - {notFoundContent || locale.notFoundContent} - - ); - } - - renderCascader() { - const { dataSource } = this.props; - if (dataSource.length === 0) { - return this.renderNotFound(); - } - - const { searchValue } = this.state; - let filteredPaths = []; - - if (searchValue) { - filteredPaths = this.filterItems(); - if (filteredPaths.length === 0) { - return this.renderNotFound(); - } - } - - const { - multiple, - useVirtual, - changeOnSelect, - checkStrictly, - canOnlyCheckLeaf, - defaultExpandedValue, - expandTriggerType, - onExpand, - listStyle, - listClassName, - loadData, - showSearch, - resultRender, - readOnly, - itemRender, - immutable, - menuProps = {}, - } = this.props; - const { value } = this.state; - - const props = { - dataSource, - value, - multiple, - useVirtual, - canOnlySelectLeaf: !changeOnSelect, - checkStrictly, - canOnlyCheckLeaf, - defaultExpandedValue, - expandTriggerType, - ref: this.saveCascaderRef, - onExpand, - listStyle, - listClassName, - loadData, - itemRender, - immutable, - }; - - if ('expandedValue' in this.props) { - props.expandedValue = this.props.expandedValue; - } - - if (!readOnly) { - props.onChange = this.handleChange; - props.onSelect = this.handleSelect; - } - if (showSearch) { - props.searchValue = searchValue; - props.filteredPaths = filteredPaths; - props.resultRender = resultRender; - props.filteredListStyle = { height: this.cascaderHeight }; - } - - return ; - } - - renderPopupContent() { - const { prefix, header, footer } = this.props; - return ( -
    - {header} - {this.renderCascader()} - {footer} -
    - ); - } - - renderPreview(others) { - const { prefix, multiple, className, renderPreview } = this.props; - const { value } = this.state; - const previewCls = classNames(className, `${prefix}form-preview`); - let items = (multiple ? this.getMultipleData(value) : this.getSingleData(value)) || []; - - if (!Array.isArray(items)) { - items = [items]; - } - - if (typeof renderPreview === 'function') { - return ( -
    - {renderPreview(items, this.props)} -
    - ); - } - - return ( -

    - {items.map(({ label }) => label).join(', ')} -

    - ); - } - - render() { - const { - prefix, - size, - hasArrow, - hasBorder, - hasClear, - label, - readOnly, - placeholder, - dataSource, - disabled, - multiple, - className, - showSearch, - popupStyle, - popupClassName, - popupContainer, - popupProps, - followTrigger, - isPreview, - resultAutoWidth, - } = this.props; - const { value, searchValue, visible } = this.state; - const others = pickOthers(Object.keys(CascaderSelect.propTypes), this.props); - // mode应与multiple api保持一致 - if (multiple && 'mode' in others && others.mode !== 'multiple') { - delete others.mode; - } - - this.updateCache(dataSource); - - if (isPreview) { - return this.renderPreview(others); - } - - const popupContent = this.renderPopupContent(); - - const props = { - prefix, - className, - size, - placeholder, - disabled, - hasArrow, - hasBorder, - hasClear, - label, - readOnly, - ref: this.saveSelectRef, - autoWidth: false, - mode: multiple ? 'multiple' : 'single', - value: multiple ? this.getMultipleData(value) : this.getSingleData(value), - onChange: this.handleClear, - onRemove: this.handleRemove, - visible, - onVisibleChange: this.handleVisibleChange, - showSearch, - onSearch: this.handleSearch, - onKeyDown: this.handleKeyDown, - popupContent, - popupStyle, - popupClassName, - popupContainer, - popupProps, - followTrigger, - }; - - if (!multiple) { - // 单选模式 select 会强制cache=true,会导致菜单展开状态的初始化不执行 - // 若用户没有手动设置cache true,这里重置为false - if (!popupProps || !popupProps.cache) { - props.popupProps = { - ...popupProps, - cache: false, - }; - } - } - - if (showSearch) { - props.popupProps = { - ...popupProps, - ref: this.getPopup, - afterOpen: this.handleAfterOpen, - }; - props.autoWidth = resultAutoWidth && !!searchValue; - } - - return ; + } +} + +export default polyfill(CascaderSelect); diff --git a/components/cascader-select/index.d.ts b/components/cascader-select/index.d.ts deleted file mode 100644 index a183b17edb..0000000000 --- a/components/cascader-select/index.d.ts +++ /dev/null @@ -1,230 +0,0 @@ -/// - -import React from 'react'; -import { CascaderProps, data, extra } from '../cascader'; -import { CommonProps } from '../util'; -import { PopupProps } from '../overlay'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -export interface CascaderSelectProps extends CascaderProps, HTMLAttributesWeak, CommonProps { - /** - * 选择框大小 - */ - size?: 'small' | 'medium' | 'large'; - name?: string; - - /** - * 选择框占位符 - */ - placeholder?: string; - - /** - * 是否禁用 - */ - disabled?: boolean; - - /** - * 是否有下拉箭头 - */ - hasArrow?: boolean; - - /** - * 是否有边框 - */ - hasBorder?: boolean; - - /** - * 是否有清除按钮 - */ - hasClear?: boolean; - - /** - * 自定义内联 label - */ - label?: React.ReactNode; - - /** - * 是否只读,只读模式下可以展开弹层但不能选 - */ - readOnly?: boolean; - - /** - * 数据源,结构可参考下方说明 - */ - dataSource?: Array; - - /** - * (非受控)默认值 - */ - defaultValue?: string | Array; - - /** - * (受控)当前值 - */ - value?: string | Array; - - valueRender?: (item: any) => React.ReactNode; - - /** - * 选中值改变时触发的回调函数 - */ - onChange?: (value: string | Array, data: data | Array, extra: extra) => void; - - /** - * 默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 - */ - defaultExpandedValue?: Array; - - /** - * 展开触发的方式 - */ - expandTriggerType?: 'click' | 'hover'; - - /** - * 是否开启虚拟滚动 - */ - useVirtual?: boolean; - - /** - * 是否多选 - */ - multiple?: boolean; - - /** - * 是否选中即发生改变, 该属性仅在单选模式下有效 - */ - changeOnSelect?: boolean; - - /** - * 是否只能勾选叶子项的checkbox,该属性仅在多选模式下有效 - */ - canOnlyCheckLeaf?: boolean; - - /** - * 父子节点是否选中不关联 - */ - checkStrictly?: boolean; - - /** - * 每列列表样式对象 - */ - listStyle?: React.CSSProperties; - - /** - * 每列列表类名 - */ - listClassName?: string; - - /** - * 选择框单选时展示结果的自定义渲染函数 - */ - displayRender?: (label: Array) => React.ReactNode; - - /** - * 渲染 item 内容的方法 - */ - itemRender?: (item: data) => React.ReactNode; - - /** - * 是否显示搜索框 - */ - showSearch?: boolean; - - /** - * 自定义搜索函数 - */ - filter?: (searchValue: string, path: Array) => boolean; - - /** - * 当搜索框值变化时回调 - */ - onSearch?: (value: string) => void; - - /** - * 搜索结果自定义渲染函数 - */ - resultRender?: (searchValue: string, path: Array) => React.ReactNode; - - /** - * 搜索结果列表是否和选择框等宽 - */ - resultAutoWidth?: boolean; - - /** - * 无数据时显示内容 - */ - notFoundContent?: React.ReactNode; - - /** - * 异步加载数据函数 - */ - loadData?: (data: {}) => void; - - /** - * 自定义下拉框头部 - */ - header?: React.ReactNode; - - /** - * 自定义下拉框底部 - */ - footer?: React.ReactNode; - - /** - * 初始下拉框是否显示 - */ - defaultVisible?: boolean; - - /** - * 当前下拉框是否显示 - */ - visible?: boolean; - - /** - * 下拉框显示或关闭时触发事件的回调函数 - */ - onVisibleChange?: (visible: boolean, type: string) => void; - - /** - * 下拉框自定义样式对象 - */ - popupStyle?: React.CSSProperties; - - /** - * 下拉框样式自定义类名 - */ - popupClassName?: string; - - /** - * 下拉框挂载的容器节点 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 是否跟随滚动 - */ - followTrigger?: boolean; - - /** - * 透传到 Popup 的属性对象 - */ - popupProps?: PopupProps; - - /** - * 是否是不可变数据 - */ - immutable?: boolean; - - /** - * 是否为预览态 - */ - isPreview?: boolean; - - renderPreview?: (value: string | Array) => React.ReactNode; -} - -export default class CascaderSelect extends React.Component {} diff --git a/components/cascader-select/index.jsx b/components/cascader-select/index.jsx deleted file mode 100644 index 2f2f34da5a..0000000000 --- a/components/cascader-select/index.jsx +++ /dev/null @@ -1,33 +0,0 @@ -import ConfigProvider from '../config-provider'; -import CascaderSelect from './cascader-select'; - -export default ConfigProvider.config(CascaderSelect, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('shape' in props) { - deprecated('shape', 'hasBorder', 'CascaderSelect'); - const { shape, ...others } = props; - props = { hasBorder: shape !== 'arrow-only', ...others }; - } - - if ('container' in props) { - deprecated('container', 'popupContainer', 'CascaderSelect'); - const { container, ...others } = props; - props = { popupContainer: container, ...others }; - } - - if ('expandTrigger' in props) { - deprecated('expandTrigger', 'expandTriggerType', 'CascaderSelect'); - const { expandTrigger, ...others } = props; - props = { expandTriggerType: expandTrigger, ...others }; - } - - if ('showItemCount' in props) { - deprecated('showItemCount', 'listStyle | listClassName', 'CascaderSelect'); - } - if ('labelWidth' in props) { - deprecated('labelWidth', 'listStyle | listClassName', 'CascaderSelect'); - } - - return props; - }, -}); diff --git a/components/cascader-select/index.tsx b/components/cascader-select/index.tsx new file mode 100644 index 0000000000..0ea36c7470 --- /dev/null +++ b/components/cascader-select/index.tsx @@ -0,0 +1,36 @@ +import ConfigProvider from '../config-provider'; +import CascaderSelect from './cascader-select'; + +export type { CascaderSelectProps, CascaderSelectDataItem } from './types'; + +export default ConfigProvider.config(CascaderSelect, { + exportNames: ['focus'], + transform: (props, deprecated) => { + if ('shape' in props) { + deprecated('shape', 'hasBorder', 'CascaderSelect'); + const { shape, hasBorder, ...others } = props; + props = { hasBorder: hasBorder ?? shape !== 'arrow-only', ...others }; + } + + if ('container' in props) { + deprecated('container', 'popupContainer', 'CascaderSelect'); + const { container, ...others } = props; + props = { popupContainer: container, ...others }; + } + + if ('expandTrigger' in props) { + deprecated('expandTrigger', 'expandTriggerType', 'CascaderSelect'); + const { expandTrigger, expandTriggerType, ...others } = props; + props = { expandTriggerType: expandTriggerType ?? expandTrigger, ...others }; + } + + if ('showItemCount' in props) { + deprecated('showItemCount', 'listStyle | listClassName', 'CascaderSelect'); + } + if ('labelWidth' in props) { + deprecated('labelWidth', 'listStyle | listClassName', 'CascaderSelect'); + } + + return props; + }, +}); diff --git a/components/cascader-select/mobile/index.jsx b/components/cascader-select/mobile/index.tsx similarity index 100% rename from components/cascader-select/mobile/index.jsx rename to components/cascader-select/mobile/index.tsx diff --git a/components/cascader-select/style.js b/components/cascader-select/style.ts similarity index 100% rename from components/cascader-select/style.js rename to components/cascader-select/style.ts diff --git a/components/cascader-select/types.ts b/components/cascader-select/types.ts new file mode 100644 index 0000000000..4a8691e7fc --- /dev/null +++ b/components/cascader-select/types.ts @@ -0,0 +1,263 @@ +import type React from 'react'; +import type { CascaderProps, CascaderDataItem, Extra } from '../cascader'; +import type { CommonProps } from '../util'; +import Overlay from '../overlay'; +import type { SelectProps, VisibleChangeType } from '../select'; + +const { Popup } = Overlay; + +interface HTMLAttributesWeak + extends Omit< + React.HTMLAttributes, + 'defaultValue' | 'onChange' | 'onSelect' | 'onFocus' | 'onBlur' + > {} + +export interface CascaderSelectDataItem extends CascaderDataItem { + pos: string; +} + +export type CascaderSelectVisibleChangeType = VisibleChangeType | 'keyboard' | 'fromCascader'; + +export interface DeprecatedProps { + /** + * @deprecated use `popupContainer` instead + */ + container?: CascaderSelectProps['popupContainer']; + /** + * @deprecated use `hasBorder` instead + */ + shape?: string; + /** + * @deprecated use `expandTriggerType` instead + */ + expandTrigger?: CascaderSelectProps['expandTriggerType']; +} + +type PickCascaderKeys = + | 'dataSource' + | 'useVirtual' + | 'multiple' + | 'canOnlyCheckLeaf' + | 'checkStrictly' + | 'resultRender' + | 'expandedValue' + | 'defaultExpandedValue' + | 'expandTriggerType' + | 'onExpand' + | 'listStyle' + | 'listClassName' + | 'loadData' + | 'itemRender' + | 'immutable'; + +/** + * @api CascaderSelect + * @remarks + * 继承 Cascader, Select 的部分属性,支持透传给 Cascader 的属性有 dataSource, useVirtual, multiple, canOnlyCheckLeaf, + * checkStrictly, resultRender, expandedValue, defaultExpandedValue, expandTriggerType, onExpand, listStyle, + * listClassName, loadData, itemRender, immutable。支持透传给 Select 的包括除上面传给 Cascader 和下方单独列出的属性以外的其他全部属性。 + * - + * inherits partial props from Cascader, support passing props to Cascader: dataSource, useVirtual, multiple, canOnlyCheckLeaf, + * checkStrictly, resultRender, expandedValue, defaultExpandedValue, expandTriggerType, onExpand, listStyle, listClassName, loadData, i + * temRender, immutable. Support passing props to Select: other Select props except those listed above and those listed below. + */ +export interface CascaderSelectProps + extends Pick, + Omit< + SelectProps, + PickCascaderKeys | 'locale' | 'onChange' | 'renderPreview' | 'menuProps' | 'filter' + >, + HTMLAttributesWeak, + CommonProps, + DeprecatedProps { + /** + * 选择框大小 + * @en size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 是否禁用 + * @en disabled + * @defaultValue false + */ + disabled?: boolean; + + /** + * 是否有下拉箭头 + * @en hasArrow + * @defaultValue true + */ + hasArrow?: boolean; + + /** + * 是否有边框 + * @en hasBorder + * @defaultValue true + */ + hasBorder?: boolean; + + /** + * 是否有清除按钮 + * @en hasClear + * @defaultValue false + */ + hasClear?: boolean; + + /** + * 是否只读,只读模式下可以展开弹层但不能选 + * @en readOnly, popup layer can be expanded but cannot be selected in read-only mode + */ + readOnly?: boolean; + + /** + *(非受控)默认值 + * @en default value(not controlled) + */ + defaultValue?: string | Array; + + /** + *(受控)当前值 + * @en current value(controlled) + */ + value?: string | Array; + + /** + * 选中值改变时触发的回调函数 + * @en callback when selected value changes + */ + onChange?: ( + value: string | Array | null, + data: CascaderDataItem | Array | null, + extra?: Extra + ) => void; + + /** + * 是否选中即发生改变,该属性仅在单选模式下有效 + * @en whether to call onChange as soon as selected, this property only works in single selection mode + * @defaultValue false + */ + changeOnSelect?: boolean; + + /** + * 选择框单选时展示结果的自定义渲染函数 + * @en custom render function of selected result + */ + displayRender?: ( + label: Array, + data: CascaderSelectDataItem + ) => React.ReactNode; + + /** + * 是否显示搜索框 + * @en show search box + * @defaultValue false + */ + showSearch?: boolean; + + /** + * 自定义搜索函数 + * @en custom search function + */ + filter?: (searchValue: string, path: CascaderSelectDataItem[]) => boolean; + + /** + * 当搜索框值变化时回调 + * @en callback when search value changes + * @version 1.23 + */ + onSearch?: (value: string) => void; + + /** + * 搜索结果列表是否和选择框等宽 + * @en whether the search result list is the same width as the selection box + * @defaultValue true + */ + resultAutoWidth?: boolean; + + /** + * 无数据时显示内容 + * @en content when no data + */ + notFoundContent?: React.ReactNode; + + /** + * 自定义下拉框头部 + * @en custom dropdown header + */ + header?: React.ReactNode; + + /** + * 自定义下拉框底部 + * @en custom dropdown footer + */ + footer?: React.ReactNode; + + /** + * 初始下拉框是否显示 + * @en visible by default + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 当前下拉框是否显示 + * @en current visible + */ + visible?: boolean; + + /** + * 下拉框显示或关闭时触发事件的回调函数 + */ + onVisibleChange?: (visible: boolean, type?: CascaderSelectVisibleChangeType) => void; + + /** + * 透传到 Popup 的属性对象 + * @en props object passed to Popup + */ + popupProps?: React.ComponentPropsWithRef; + + /** + * 是否为预览态 + * @en whether it is in preview mode + * @defaultValue false + */ + isPreview?: boolean; + + /** + * 自定义预览态 + * @en custom preview + */ + renderPreview?: ( + value: CascaderSelectDataItem | CascaderSelectDataItem[], + props: CascaderSelectProps + ) => React.ReactNode; + /** + * 是否支持树形勾选 + * @en whether to support tree check + * @skip + */ + treeCheckable?: boolean; + /** + * 透传到 Cascader 的属性对象;focusedKey、onItemFocus、className、style、focusable、isSelectIconRight 传入无效,其中 onBlur 在 filter 下传入无效 + * @en props object passed to Cascader:The parameters focusedKey, onItemFocus, className, style, focusable, and isSelectIconRight are invalid. Additionally, onBlur is invalid when passed under the filter + */ + menuProps?: Omit; + /** + * 是否在选中项后清空搜索框,只在 multiple 为 true 时有效 + * @en Whether the current search will be cleared on selecting an item. Only applies when multiple is true + * @defaultValue false + */ + autoClearSearchValue?: boolean; +} + +export interface CascaderSelectState { + value: string[]; + searchValue: string; + visible: boolean; +} diff --git a/components/cascader/__docs__/adaptor/index.jsx b/components/cascader/__docs__/adaptor/index.jsx deleted file mode 100644 index 8a09f1fa53..0000000000 --- a/components/cascader/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import { Types, NodeType, parseData } from '@alifd/adaptor-helper'; -import { Cascader, Icon } from '@alifd/next'; - -let index = 1000; -const createDataSource = (list, map = {}) => { - if (!list) return []; - return list.filter((item) => item.type === NodeType.node).map(({ value, children, state }) => { - const key = String(index++); - if (state === 'active') { - if (!children || children.length === 0) { - map.selecteds.push(key); - } else { - map.expandeds.push(key); - } - } - - return { - value: key, - label: value, - disabled: state === 'disabled', - children: createDataSource(children, map), - }; - }); -} -export default { - name: 'Cascader', - editor: () => ({ - props: [{ - name: 'checkbox', - type: Types.bool, - default: false - }, { - name: 'width', - type: Types.number, - default: 120 - }], - data: { - active: true, - disable: true, - icon: true, - default: 'Option1\n\tOption1-1\n\tOption1-2\n\tOption1-3\n\tOption1-4\n\tOption1-5\n\tOption1-6\nOption2\n\tOption2-1\n\tOption2-2\n\tOption2-3\n\tOption2-4\n\tOption2-5\n\tOption2-6\nOption3\n\tOption3-1\n\tOption3-2\n\tOption3-3\n\tOption3-4\n\tOption3-5\n\tOption3-6\nOption4\n\tOption4-1\n\tOption4-2\n\tOption4-3\n\tOption4-4\n\tOption4-5\n\tOption4-6\nOption5\n\tOption5-1\n\tOption5-2\n\tOption5-3\n\tOption5-4\n\tOption5-5\n\tOption5-6\nOption6\n\tOption6-1\n\tOption6-2\n\tOption6-3\n\tOption6-4\n\tOption6-5\n\tOption6-6', - } - }), - adaptor: ({ checkbox, width, data, ...others }) => { - const list = parseData(data); - const map = { selecteds: [], expandeds: [] }; - const dataSource = createDataSource(list, map); - const value = map.selecteds; - const itemRender = ({ label = '' }) => { - return label.replace(/(\[.*?\])/g, '\n$1\n').split('\n').filter(v=> !!v) - .map((d, i) => { - let icon; - switch (true) { - case /^\[(.*)\]$/.test(d): - icon = RegExp.$1; - if (!icon) return ''; - return ; - default: - return d; - } - }); - }; - - return ; - } -}; diff --git a/components/cascader/__docs__/adaptor/index.tsx b/components/cascader/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..77e0a37ba3 --- /dev/null +++ b/components/cascader/__docs__/adaptor/index.tsx @@ -0,0 +1,97 @@ +import React from 'react'; +import { Types, NodeType, parseData } from '@alifd/adaptor-helper'; +import { Cascader, Icon } from '@alifd/next'; + +let index = 1000; +const createDataSource = ( + list: Array<{ type: string; value: string; children: any[]; state: string }>, + map: { selecteds?: string[]; expandeds?: string[] } = {} +): any => { + if (!list) return []; + return list + .filter(item => item.type === NodeType.node) + .map(({ value, children, state }) => { + const key = String(index++); + if (state === 'active') { + if (!children || children.length === 0) { + map.selecteds!.push(key); + } else { + map.expandeds!.push(key); + } + } + + return { + value: key, + label: value, + disabled: state === 'disabled', + children: createDataSource(children, map), + }; + }); +}; +export default { + name: 'Cascader', + editor: () => ({ + props: [ + { + name: 'checkbox', + type: Types.bool, + default: false, + }, + { + name: 'width', + type: Types.number, + default: 120, + }, + ], + data: { + active: true, + disable: true, + icon: true, + default: + 'Option1\n\tOption1-1\n\tOption1-2\n\tOption1-3\n\tOption1-4\n\tOption1-5\n\tOption1-6\nOption2\n\tOption2-1\n\tOption2-2\n\tOption2-3\n\tOption2-4\n\tOption2-5\n\tOption2-6\nOption3\n\tOption3-1\n\tOption3-2\n\tOption3-3\n\tOption3-4\n\tOption3-5\n\tOption3-6\nOption4\n\tOption4-1\n\tOption4-2\n\tOption4-3\n\tOption4-4\n\tOption4-5\n\tOption4-6\nOption5\n\tOption5-1\n\tOption5-2\n\tOption5-3\n\tOption5-4\n\tOption5-5\n\tOption5-6\nOption6\n\tOption6-1\n\tOption6-2\n\tOption6-3\n\tOption6-4\n\tOption6-5\n\tOption6-6', + }, + }), + adaptor: ({ checkbox, width, data, ...others }: any) => { + const list = parseData(data); + const map = { selecteds: [], expandeds: [] }; + const dataSource = createDataSource(list as any, map); + const value = map.selecteds; + const itemRender = ({ label = '' }) => { + return label + .replace(/(\[.*?\])/g, '\n$1\n') + .split('\n') + .filter(v => !!v) + .map((d, i) => { + let icon; + switch (true) { + case /^\[(.*)\]$/.test(d): + icon = RegExp.$1; + if (!icon) return ''; + return ( + + ); + default: + return d; + } + }); + }; + + return ( + + ); + }, +}; diff --git a/components/cascader/__docs__/demo/basic/index.tsx b/components/cascader/__docs__/demo/basic/index.tsx index 276688a124..ca577c0c2a 100644 --- a/components/cascader/__docs__/demo/basic/index.tsx +++ b/components/cascader/__docs__/demo/basic/index.tsx @@ -1,19 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Cascader } from '@alifd/next'; +import type { CascaderProps } from '@alifd/next/types/cascader'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - label: '', - data: [], - }; - - this.handleChange = this.handleChange.bind(this); - } + state = { + label: '', + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -25,13 +20,13 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); this.setState({ - label: extra.selectedPath.map(d => d.label).join(' / '), + label: extra.selectedPath!.map(d => d.label).join(' / '), }); - } + }; render() { return ( diff --git a/components/cascader/__docs__/demo/check-strictly/index.tsx b/components/cascader/__docs__/demo/check-strictly/index.tsx index 80cbf13062..faef1842d3 100644 --- a/components/cascader/__docs__/demo/check-strictly/index.tsx +++ b/components/cascader/__docs__/demo/check-strictly/index.tsx @@ -1,17 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox, Cascader } from '@alifd/next'; +import type { CascaderProps } from '@alifd/next/types/cascader'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - data: [], - checkStrictly: false, - }; - } + state = { + data: [], + checkStrictly: false, + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -26,7 +23,7 @@ class Demo extends React.Component { }); }; - handleChange = (value, data, extra) => { + handleChange: CascaderProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader/__docs__/demo/custom-style/index.tsx b/components/cascader/__docs__/demo/custom-style/index.tsx index c68618e279..add20029d1 100644 --- a/components/cascader/__docs__/demo/custom-style/index.tsx +++ b/components/cascader/__docs__/demo/custom-style/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Cascader } from '@alifd/next'; +import type { CascaderDataItem } from '@alifd/next/types/cascader'; const dataSource = [ { @@ -41,7 +42,7 @@ const dataSource = [ }, ]; -function itemRender(itemData) { +function itemRender(itemData: CascaderDataItem) { return `${itemData.label}(${itemData.value})`; } diff --git a/components/cascader/__docs__/demo/dynamic/index.tsx b/components/cascader/__docs__/demo/dynamic/index.tsx index e5cf1d2941..1a5ecec71b 100644 --- a/components/cascader/__docs__/demo/dynamic/index.tsx +++ b/components/cascader/__docs__/demo/dynamic/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Cascader } from '@alifd/next'; +import type { CascaderProps } from '@alifd/next/types/cascader'; import 'whatwg-fetch'; const dataSource = [ @@ -11,17 +12,11 @@ const dataSource = [ ]; class Demo extends React.Component { - constructor(props) { - super(props); + state = { + dataSource, + }; - this.state = { - dataSource, - }; - - this.onLoadData = this.onLoadData.bind(this); - } - - onLoadData(data) { + onLoadData: CascaderProps['loadData'] = data => { console.log(data); return new Promise(resolve => { @@ -53,11 +48,13 @@ class Demo extends React.Component { }, ], }, - resolve + () => { + resolve(''); + } ); }, 500); }); - } + }; render() { return ( diff --git a/components/cascader/__docs__/demo/expand-trigger/index.tsx b/components/cascader/__docs__/demo/expand-trigger/index.tsx index 650977e5e8..50d3f547a5 100644 --- a/components/cascader/__docs__/demo/expand-trigger/index.tsx +++ b/components/cascader/__docs__/demo/expand-trigger/index.tsx @@ -1,22 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Radio, Cascader } from '@alifd/next'; +import type { CascaderProps } from '@alifd/next/types/cascader'; +import type { GroupProps } from '@alifd/next/types/radio'; import 'whatwg-fetch'; const RadioGroup = Radio.Group; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - triggerType: 'click', - data: [], - }; - - this.handleChange = this.handleChange.bind(this); - this.handleTriggerTypeChange = this.handleTriggerTypeChange.bind(this); - } + state = { + triggerType: 'click', + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -25,15 +20,15 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); - } + }; - handleTriggerTypeChange(triggerType) { + handleTriggerTypeChange: GroupProps['onChange'] = triggerType => { this.setState({ triggerType, }); - } + }; render() { return ( @@ -47,7 +42,7 @@ class Demo extends React.Component { />
    diff --git a/components/cascader/__docs__/demo/multiple/index.tsx b/components/cascader/__docs__/demo/multiple/index.tsx index e06e44c750..768b7e14e4 100644 --- a/components/cascader/__docs__/demo/multiple/index.tsx +++ b/components/cascader/__docs__/demo/multiple/index.tsx @@ -1,19 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Cascader } from '@alifd/next'; +import type { CascaderDataItem, CascaderProps } from '@alifd/next/types/cascader'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - label: '', - data: [], - }; - - this.handleChange = this.handleChange.bind(this); - } + state = { + label: '', + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -24,13 +19,13 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange(value, data, extra) { + handleChange: CascaderProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); this.setState({ - label: data.map(d => d.label).join(', '), + label: (data as CascaderDataItem[]).map(d => d.label).join(', '), }); - } + }; render() { return ( diff --git a/components/cascader/__docs__/demo/only-leaf/index.tsx b/components/cascader/__docs__/demo/only-leaf/index.tsx index 8e76901797..44966ab60e 100644 --- a/components/cascader/__docs__/demo/only-leaf/index.tsx +++ b/components/cascader/__docs__/demo/only-leaf/index.tsx @@ -1,16 +1,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Cascader } from '@alifd/next'; +import type { CascaderProps } from '@alifd/next/types/cascader'; import 'whatwg-fetch'; class Demo extends React.Component { - constructor(props) { - super(props); - - this.state = { - value: null, - }; - } + state = { + value: null, + data: [], + }; componentDidMount() { fetch('https://os.alipayobjects.com/rmsportal/ODDwqcDFTLAguOvWEolX.json') @@ -19,7 +17,7 @@ class Demo extends React.Component { .catch(e => console.log(e)); } - handleChange = (value, data, extra) => { + handleChange: CascaderProps['onChange'] = (value, data, extra) => { console.log(value, data, extra); }; diff --git a/components/cascader/__docs__/index.en-us.md b/components/cascader/__docs__/index.en-us.md index 8a8d94b315..02ebf3ad11 100644 --- a/components/cascader/__docs__/index.en-us.md +++ b/components/cascader/__docs__/index.en-us.md @@ -11,76 +11,130 @@ ### When To Use -- Applies to the interactive way of selecting from a set of related data sets. -- Cascading is an effective method of saving screen space due to the hidden subset directory. -- The number of levels depends on the business needs, and it is not recommended to exceed 5 levels. -- Cascading is used for form scenes. It can be used independently on the page or in combination with other elements, such as cascading options. +- Applies to the interactive way of selecting from a set of related data sets. +- Cascading is an effective method of saving screen space due to the hidden subset directory. +- The number of levels depends on the business needs, and it is not recommended to exceed 5 levels. +- Cascading is used for form scenes. It can be used independently on the page or in combination with other elements, such as cascading options. ## API ### Cascader -| Param | Description | Type | Default Value | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------ | -| dataSource | data source, structure can refer to the following document | Array<Object> | \[] | -| defaultValue | (under uncontrol) default value | String/Array<String> | null | -| value | (under control) current value | String/Array<String> | - | -| onChange | callback triggered when value changes

    **signatures**:
    Function(value: String/Array, data: Object/Array, extra: Object) => void
    **params**:
    _value_: {String/Array} selected value, a single value is returned when single select, and an array is returned when multiple select
    _data_: {Object/Array} selected data, including value, label, returns a single value when single select, returns an array when multiple select, parent and child nodes are selected at the same time, only the parent node is returned
    _extra_: {Object} extra param
    _extra.selectedPath_: {Array} path of the selected data when single selecte
    _extra.checked_: {Boolean} whether is checked when multiple select
    _extra.currentData_: {Object} current operation data when multiple select
    _extra.checkedData_: {Array} all checked data when multiple select
    _extra.indeterminateData_: {Array} indeterminate data when multile select | Function | - | -| defaultExpandedValue | (under uncontrol) default expanded value, if not set, the component will be automatically set according to defaultValue/value | Array<String> | - | -| expandedValue | (under control) current expanded value | Array<String> | - | -| expandTriggerType | expand trigger type

    **options**:
    'click', 'hover' | Enum | 'click' | -| onExpand | callback triggered when expand or collapse

    **signatures**:
    Function(expandedValue: Array) => void
    **params**:
    _expandedValue_: {Array} an array of list expanded values | Function | - | -| multiple | whether is multiple select | Boolean | false | -| canOnlySelectLeaf | whether only leaf nodes can be selected when single select | Boolean | false | -| canOnlyCheckLeaf | whether only leaf nodes can be checked when multiple select | Boolean | false | -| checkStrictly | whether selection of parent and child nodes are related | Boolean | false | -| listStyle | style of list | Object | - | -| listClassName | class name of list | String | - | -| itemRender | render function of item

    **signatures**:
    Function(data: Object) => ReactNode
    **params**:
    _data_: {Object} data
    **returns**:
    {ReactNode} content of item
    | Function | item => item.label | -| loadData | asynchronous data loading function

    **signatures**:
    Function(data: Object) => void
    **params**:
    _data_: {Object} clicked item | Function | - | -| immutable | whether support immutable dataSource | Boolean | false | +| Param | Description | Type | Default Value | Required | Supported Version | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------- | ----------------- | +| dataSource | Data source | Array\ | [] | | - | +| defaultValue | Default value | string \| Array\ | - | | - | +| value | Current value | string \| Array\ | - | | - | +| onChange | Callback when value changed

    **signature**:
    **params**:
    _value_: Selected value, single value when single select, array when multiple select
    _data_: Selected data, including value and label, single value when single select, array when multiple select
    _extra_: Extra parameters | (
    value: string \| Array\,
    data: CascaderDataItem \| Array\,
    extra: Extra
    ) => void | - | | - | +| onSelect | Callback when selected

    **signature**:
    **params**:
    _v_: Selected value
    _data_: Selected data, including value and label
    _extra_: Extra parameters | (v: string, data: CascaderDataItemWithPosInfo, extra: Extra) => void | - | | - | +| defaultExpandedValue | Default expanded value | Array\ | - | | - | +| expandedValue | Current expanded value | Array\ | - | | - | +| expandTriggerType | Expand trigger type | 'click' \| 'hover' | 'click' | | - | +| onExpand | Callback when expanded

    **signature**:
    **params**:
    _expandedValue_: Expanded value | (expandedValue: Array\) => void | - | | - | +| useVirtual | Use virtual scroll, recommend set listStyle fixed width when enable | boolean | false | | - | +| multiple | Multiple | boolean | false | | - | +| canOnlySelectLeaf | Can only select leaf when single select | boolean | false | | - | +| canOnlyCheckLeaf | Can only check leaf when multiple select | boolean | false | | - | +| checkStrictly | Check parent and child not associated | boolean | false | | - | +| listStyle | List style | React.CSSProperties | - | | - | +| listClassName | List class | string | - | | - | +| itemRender | List item render

    **signature**:
    **params**:
    _data_: Data
    _props_: List item props
    **return**:
    List item content | (data: CascaderDataItem, props: ItemProps) => React.ReactNode | (item: CascaderDataItem) =\> item.label | | - | +| loadData | Async load data function

    **signature**:
    **params**:
    _data_: Current click data
    _source_: Current click data, source is original object | (data: CascaderDataItem, source: CascaderDataItem) => Promise\ | - | | - | +| immutable | Immutable | boolean | false | | 1.23.0 | + +### CascaderDataItem + +```typescript +export type CascaderDataItem = { + value: string; + label?: React.ReactNode; + disabled?: boolean; + checkboxDisabled?: boolean; + children?: Array; + title?: string; + [propName: string]: unknown; +}; +``` + +### CascaderDataItemWithPosInfo + +```typescript +export type CascaderDataItemWithPosInfo = CascaderDataItem & { + /** + * 位置信息 + */ + pos: string; + _source?: CascaderDataItem; +}; +``` + +### Extra + +```typescript +export type Extra = { + /** + * 单选时选中的数据的路径 + */ + selectedPath?: Array; + /** + * 多选时当前的操作是选中还是取消选中 + */ + checked?: boolean; + /** + * 多选时当前操作的数据 + */ + currentData?: CascaderDataItem; + /** + * 多选时所有被选中的数据 + */ + checkedData?: Array; + /** + * 多选时半选的数据 + */ + indeterminateData?: Array; +}; +``` ### Data structure of dataSource ```js -const dataSource = [{ - value: '2974', - label: 'A', - children: [ - { value: '2975', label: 'B', disabled: true }, - { value: '2976', label: 'C', checkboxDisabled: true }, - { value: '2977', label: 'D' }, - { value: '2978', label: 'E' }, - { value: '2979', label: 'F' }, - { value: '4208', label: 'G' }, - { value: '4209', label: 'H' }, - { value: '4210', label: 'I' }, - { value: '4211', label: 'J' }, - { value: '4212', label: 'K' }, - { value: '4213', label: 'L' }, - { value: '4214', label: 'M' }, - { value: '4215', label: 'N' }, - { value: '4388', label: 'O' }, - { value: '610127', label: 'P' } - ] -}]; +const dataSource = [ + { + value: '2974', + label: 'A', + children: [ + { value: '2975', label: 'B', disabled: true }, + { value: '2976', label: 'C', checkboxDisabled: true }, + { value: '2977', label: 'D' }, + { value: '2978', label: 'E' }, + { value: '2979', label: 'F' }, + { value: '4208', label: 'G' }, + { value: '4209', label: 'H' }, + { value: '4210', label: 'I' }, + { value: '4211', label: 'J' }, + { value: '4212', label: 'K' }, + { value: '4213', label: 'L' }, + { value: '4214', label: 'M' }, + { value: '4215', label: 'N' }, + { value: '4388', label: 'O' }, + { value: '610127', label: 'P' }, + ], + }, +]; ``` The custom attribute of item in the array is also transparently passed to the data parameter of the onChange function. - - ## ARIA and KeyBoard -| 按键 | 说明 | -| :---------- | :------------------------------ | -| Left Arrow | Get the previous item focus of the current item of same level | -| Right Arrow | Get the next item focus of the current item of same level | +| 按键 | 说明 | +| :---------- | :--------------------------------------------------------------------------------------- | +| Left Arrow | Get the previous item focus of the current item of same level | +| Right Arrow | Get the next item focus of the current item of same level | | Tab | Enter the child element of the current item and get the first child element as the focus | -| Esc | Returns the parent of the current item and gets the focus | -| SPACE | Select current item | - +| Esc | Returns the parent of the current item and gets the focus | +| SPACE | Select current item | diff --git a/components/cascader/__docs__/index.md b/components/cascader/__docs__/index.md index e39fa539eb..329af9ba52 100644 --- a/components/cascader/__docs__/index.md +++ b/components/cascader/__docs__/index.md @@ -20,53 +20,111 @@ ### Cascader -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------- | ------------------ | ---- | -| dataSource | 数据源,结构可参考下方说明 | Array<Object> | \[] | | -| defaultValue | (非受控)默认值 | String/Array<String> | null | | -| value | (受控)当前值 | String/Array<String> | - | | -| onChange | 选中值改变时触发的回调函数

    **签名**:
    Function(value: String/Array, data: Object/Array, extra: Object) => void
    **参数**:
    _value_: {String/Array} 选中的值,单选时返回单个值,多选时返回数组
    _data_: {Object/Array} 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点
    _extra_: {Object} 额外参数
    _extra.selectedPath_: {Array} 单选时选中的数据的路径
    _extra.checked_: {Boolean} 多选时当前的操作是选中还是取消选中
    _extra.currentData_: {Object} 多选时当前操作的数据
    _extra.checkedData_: {Array} 多选时所有被选中的数据
    _extra.indeterminateData_: {Array} 多选时半选的数据 | Function | - | | -| defaultExpandedValue | (非受控)默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 | Array<String> | - | | -| expandedValue | (受控)当前展开值 | Array<String> | - | | -| expandTriggerType | 展开触发的方式

    **可选值**:
    'click', 'hover' | Enum | 'click' | | -| onExpand | 展开时触发的回调函数

    **签名**:
    Function(expandedValue: Array) => void
    **参数**:
    _expandedValue_: {Array} 各列展开值的数组 | Function | - | | -| useVirtual | 是否开启虚拟滚动 | Boolean | false | | -| multiple | 是否多选 | Boolean | false | | -| canOnlySelectLeaf | 单选时是否只能选中叶子节点 | Boolean | false | | -| canOnlyCheckLeaf | 多选时是否只能选中叶子节点 | Boolean | false | | -| checkStrictly | 父子节点是否选中不关联 | Boolean | false | | -| listStyle | 每列列表样式对象 | Object | - | | -| listClassName | 每列列表类名 | String | - | | -| itemRender | 每列列表项渲染函数

    **签名**:
    Function(data: Object) => ReactNode
    **参数**:
    _data_: {Object} 数据
    **返回值**:
    {ReactNode} 列表项内容
    | Function | item => item.label | | -| loadData | 异步加载数据函数

    **签名**:
    Function(data: Object, source: Object) => void
    **参数**:
    _data_: {Object} 当前点击异步加载的数据
    _source_: {Object} 当前点击数据,source是原始对象 | Function | - | | -| immutable | 是否是不可变数据 | Boolean | false | 1.23 | +Cascader 支持属性透传给 Menu,但会忽略 onSelect、value、onChange、defaultValue、focusedKey、onItemFocus、focusable、isSelectIconRight、onBlur 等和 Cascader 同名的属性 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- | -------- | -------- | +| dataSource | 数据源 | Array\ | [] | | - | +| defaultValue | (非受控)默认值 | string \| Array\ | - | | - | +| value | (受控)当前值 | string \| Array\ | - | | - | +| onChange | 选中值改变时触发的回调函数

    **签名**:
    **参数**:
    _value_: 选中的值,单选时返回单个值,多选时返回数组
    _data_: 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点
    _extra_: 额外参数 | (
    value: string \| Array\,
    data: CascaderDataItem \| Array\,
    extra: Extra
    ) => void | - | | - | +| onSelect | 选中时触发的回调函数

    **签名**:
    **参数**:
    _v_: 选中的值
    _data_: 选中的数据,包括 value 和 label
    _extra_: 额外参数 | (v: string, data: CascaderDataItemWithPosInfo, extra: Extra) => void | - | | - | +| defaultExpandedValue | (非受控)默认展开值 | Array\ | - | | - | +| expandedValue | (受控)当前展开值 | Array\ | - | | - | +| expandTriggerType | 展开触发的方式 | 'click' \| 'hover' | 'click' | | - | +| onExpand | 展开时触发的回调函数

    **签名**:
    **参数**:
    _expandedValue_: 各列展开值的数组 | (expandedValue: Array\) => void | - | | - | +| useVirtual | 是否开启虚拟滚动,开启后建议设置 listStyle 固定列宽 | boolean | false | | - | +| multiple | 是否多选 | boolean | false | | - | +| canOnlySelectLeaf | 单选时是否只能选中叶子节点 | boolean | false | | - | +| canOnlyCheckLeaf | 多选时是否只能选中叶子节点 | boolean | false | | - | +| checkStrictly | 父子节点是否选中不关联 | boolean | false | | - | +| listStyle | 每列列表样式对象 | React.CSSProperties | - | | - | +| listClassName | 每列列表类名 | string | - | | - | +| itemRender | 每列列表项渲染函数

    **签名**:
    **参数**:
    _data_: 数据
    _props_: 列表项属性
    **返回值**:
    列表项内容 | (data: CascaderDataItem, props: ItemProps) => React.ReactNode | (item: CascaderDataItem) =\> item.label | | - | +| loadData | 异步加载数据函数,source 是原始对象

    **签名**:
    **参数**:
    _data_: 当前点击异步加载的数据
    _source_: 当前点击数据,source 是原始对象 | (data: CascaderDataItem, source: CascaderDataItem) => Promise\ | - | | - | +| immutable | 是否是不可变数据 | boolean | false | | 1.23.0 | + +### CascaderDataItem + +```typescript +export type CascaderDataItem = { + value: string; + label?: React.ReactNode; + disabled?: boolean; + checkboxDisabled?: boolean; + children?: Array; + title?: string; + [propName: string]: unknown; +}; +``` + +### CascaderDataItemWithPosInfo + +```typescript +export type CascaderDataItemWithPosInfo = CascaderDataItem & { + /** + * 位置信息 + */ + pos: string; + _source?: CascaderDataItem; +}; +``` + +### Extra + +```typescript +export type Extra = { + /** + * 单选时选中的数据的路径 + */ + selectedPath?: Array; + /** + * 多选时当前的操作是选中还是取消选中 + */ + checked?: boolean; + /** + * 多选时当前操作的数据 + */ + currentData?: CascaderDataItem; + /** + * 多选时所有被选中的数据 + */ + checkedData?: Array; + /** + * 多选时半选的数据 + */ + indeterminateData?: Array; +}; +``` ### dataSource数据结构 ```js -const dataSource = [{ - value: '2974', - label: '西安', - children: [ - { value: '2975', label: '西安市', disabled: true }, - { value: '2976', label: '高陵县', checkboxDisabled: true }, - { value: '2977', label: '蓝田县' }, - { value: '2978', label: '户县' }, - { value: '2979', label: '周至县' }, - { value: '4208', label: '灞桥区' }, - { value: '4209', label: '长安区' }, - { value: '4210', label: '莲湖区' }, - { value: '4211', label: '临潼区' }, - { value: '4212', label: '未央区' }, - { value: '4213', label: '新城区' }, - { value: '4214', label: '阎良区' }, - { value: '4215', label: '雁塔区' }, - { value: '4388', label: '碑林区' }, - { value: '610127', label: '其它区' } - ] -}]; +const dataSource = [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市', disabled: true }, + { value: '2976', label: '高陵县', checkboxDisabled: true }, + { value: '2977', label: '蓝田县' }, + { value: '2978', label: '户县' }, + { value: '2979', label: '周至县' }, + { value: '4208', label: '灞桥区' }, + { value: '4209', label: '长安区' }, + { value: '4210', label: '莲湖区' }, + { value: '4211', label: '临潼区' }, + { value: '4212', label: '未央区' }, + { value: '4213', label: '新城区' }, + { value: '4214', label: '阎良区' }, + { value: '4215', label: '雁塔区' }, + { value: '4388', label: '碑林区' }, + { value: '610127', label: '其它区' }, + ], + }, +]; ``` 数组中 Item 的自定义属性也会被透传到 onChange 函数的 data 参数中。 @@ -75,10 +133,10 @@ const dataSource = [{ ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---------- | :--------------------- | -| Left Arrow | 获取同级当前项前一项焦点 | -| Right Arrow | 获取同级当前项后一项焦点 | +| 按键 | 说明 | +| :---------- | :------------------------------------------- | +| Left Arrow | 获取同级当前项前一项焦点 | +| Right Arrow | 获取同级当前项后一项焦点 | | Tab | 进入当前项的子元素,并获取第一个子元素为焦点 | -| Esc | 返回当前项的父元素并获取焦点 | -| SPACE | 选择当前项 | +| Esc | 返回当前项的父元素并获取焦点 | +| SPACE | 选择当前项 | diff --git a/components/cascader/__docs__/theme/index.jsx b/components/cascader/__docs__/theme/index.jsx deleted file mode 100644 index 75ad69b064..0000000000 --- a/components/cascader/__docs__/theme/index.jsx +++ /dev/null @@ -1,5754 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import '../../style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import Cascader from '../../index'; - -const i18nMap = { - 'en-us': { - option: 'Option' - }, - 'zh-cn': { - option: '选项' - } -}; - -const createDataSource = (label, hasDisabled) => { - const dataSource = [{ - "children": [{ - "value": "2974", - "label": "西安", - "children": [ - { "value": "2975", "label": "西安市" }, - { "value": "2976", "label": "高陵县" }, - { "value": "2977", "label": "蓝田县" }, - { "value": "2978", "label": "户县" }, - { "value": "2979", "label": "周至县" }, - { "value": "4208", "label": "灞桥区" }, - { "value": "4209", "label": "长安区" }, - { "value": "4210", "label": "莲湖区" }, - { "value": "4211", "label": "临潼区" }, - { "value": "4212", "label": "未央区" }, - { "value": "4213", "label": "新城区" }, - { "value": "4214", "label": "阎良区" }, - { "value": "4215", "label": "雁塔区" }, - { "value": "4388", "label": "碑林区" }, - { "value": "610127", "label": "其它区" } - ] - }, { - "value": "2980", - "label": "铜川", - "children": [ - { "value": "2981", "label": "铜川市" }, - { "value": "2982", "label": "宜君县" }, - { "value": "4204", "label": "王益区" }, - { "value": "4205", "label": "耀州区" }, - { "value": "4206", "label": "印台区" }, - { "value": "610223", "label": "其它区" } - ] - }, { - "value": "2983", - "label": "宝鸡", - "children": [ - { "value": "2984", "label": "宝鸡市" }, - { "value": "2986", "label": "岐山县" }, - { "value": "2987", "label": "凤翔县" }, - { "value": "2989", "label": "太白县" }, - { "value": "2990", "label": "麟游县" }, - { "value": "2991", "label": "扶风县" }, - { "value": "2992", "label": "千阳县" }, - { "value": "2993", "label": "眉县" }, - { "value": "2994", "label": "凤县" }, - { "value": "2988", "label": "陇县" }, - { "value": "4200", "label": "陈仓区" }, - { "value": "4201", "label": "渭滨区" }, - { "value": "4387", "label": "金台区" }, - { "value": "610332", "label": "其它区" } - ] - }, { - "value": "2995", - "label": "咸阳", - "children": [ - { "value": "2996", "label": "咸阳市" }, - { "value": "2997", "label": "兴平市" }, - { "value": "2998", "label": "礼泉县" }, - { "value": "2999", "label": "泾阳县" }, - { "value": "3001", "label": "三原县" }, - { "value": "3002", "label": "彬县" }, - { "value": "3003", "label": "旬邑县" }, - { "value": "3004", "label": "长武县" }, - { "value": "3005", "label": "乾县" }, - { "value": "3006", "label": "武功县" }, - { "value": "3007", "label": "淳化县" }, - { "value": "3000", "label": "永寿县" }, - { "value": "4216", "label": "秦都区" }, - { "value": "4217", "label": "渭城区" }, - { "value": "4218", "label": "杨凌区" }, - { "value": "610482", "label": "其它区" } - ] - }, { - "value": "3008", - "label": "渭南", - "children": [ - { "value": "3009", "label": "渭南市" }, - { "value": "3010", "label": "韩城市" }, - { "value": "3011", "label": "华阴市" }, - { "value": "3013", "label": "潼关县" }, - { "value": "3014", "label": "白水县" }, - { "value": "3015", "label": "澄城县" }, - { "value": "3016", "label": "华县" }, - { "value": "3017", "label": "合阳县" }, - { "value": "3018", "label": "富平县" }, - { "value": "3019", "label": "大荔县" }, - { "value": "3012", "label": "蒲城县" }, - { "value": "4207", "label": "临渭区" }, - { "value": "610583", "label": "其它区" } - ] - }, { - "value": "3020", - "label": "延安", - "children": [ - { "value": "3021", "label": "延安市" }, - { "value": "3022", "label": "安塞县" }, - { "value": "3023", "label": "洛川县" }, - { "value": "3024", "label": "子长县" }, - { "value": "3025", "label": "黄陵县" }, - { "value": "3026", "label": "延川县" }, - { "value": "3027", "label": "富县" }, - { "value": "3028", "label": "延长县" }, - { "value": "3029", "label": "甘泉县" }, - { "value": "3030", "label": "宜川县" }, - { "value": "3031", "label": "志丹县" }, - { "value": "3032", "label": "黄龙县" }, - { "value": "3033", "label": "吴起县" }, - { "value": "4219", "label": "宝塔区" }, - { "value": "610633", "label": "其它区" } - ] - }, { - "value": "3034", - "label": "汉中", - "children": [ - { "value": "3035", "label": "汉中市" }, - { "value": "3036", "label": "留坝县" }, - { "value": "3037", "label": "镇巴县" }, - { "value": "3038", "label": "城固县" }, - { "value": "3039", "label": "南郑县" }, - { "value": "3040", "label": "洋县" }, - { "value": "3041", "label": "宁强县" }, - { "value": "3042", "label": "佛坪县" }, - { "value": "3043", "label": "勉县" }, - { "value": "3044", "label": "西乡县" }, - { "value": "3045", "label": "略阳县" }, - { "value": "4202", "label": "汉台区" }, - { "value": "610731", "label": "其它区" } - ] - }, { - "value": "3046", - "label": "榆林", - "children": [ - { "value": "3047", "label": "榆林市" }, - { "value": "3048", "label": "清涧县" }, - { "value": "3049", "label": "绥德县" }, - { "value": "3050", "label": "神木县" }, - { "value": "3051", "label": "佳县" }, - { "value": "3053", "label": "子洲县" }, - { "value": "3054", "label": "靖边县" }, - { "value": "3055", "label": "横山县" }, - { "value": "3056", "label": "米脂县" }, - { "value": "3057", "label": "吴堡县" }, - { "value": "3058", "label": "定边县" }, - { "value": "3052", "label": "府谷县" }, - { "value": "4220", "label": "榆阳区" }, - { "value": "610832", "label": "其它区" } - ] - }, { - "value": "3059", - "label": "安康", - "children": [ - { "value": "3060", "label": "安康市" }, - { "value": "3061", "label": "紫阳县" }, - { "value": "3062", "label": "岚皋县" }, - { "value": "3063", "label": "旬阳县" }, - { "value": "3065", "label": "平利县" }, - { "value": "3066", "label": "石泉县" }, - { "value": "3067", "label": "宁陕县" }, - { "value": "3068", "label": "白河县" }, - { "value": "3069", "label": "汉阴县" }, - { "value": "3064", "label": "镇坪县" }, - { "value": "4199", "label": "汉滨区" }, - { "value": "610930", "label": "其它区" } - ] - }, { - "value": "3070", - "label": "商洛", - "children": [ - { "value": "3071", "label": "商洛市" }, - { "value": "3072", "label": "镇安县" }, - { "value": "3073", "label": "山阳县" }, - { "value": "3074", "label": "洛南县" }, - { "value": "3075", "label": "商南县" }, - { "value": "3076", "label": "丹凤县" }, - { "value": "3077", "label": "柞水县" }, - { "value": "4203", "label": "商州区" }, - { "value": "611027", "label": "其它区" } - ] - }], - "value": "2973", - "label": "陕西" - }, { - "children": [{ - "value": "3079", - "label": "成都", - "children": [ - { "value": "3080", "label": "成都市" }, - { "value": "3081", "label": "都江堰市" }, - { "value": "3082", "label": "彭州市" }, - { "value": "3083", "label": "邛崃市" }, - { "value": "3084", "label": "崇州市" }, - { "value": "3085", "label": "金堂县" }, - { "value": "3086", "label": "郫县" }, - { "value": "3087", "label": "新津县" }, - { "value": "3088", "label": "双流县" }, - { "value": "3089", "label": "蒲江县" }, - { "value": "3090", "label": "大邑县" }, - { "value": "4240", "label": "成华区" }, - { "value": "4241", "label": "金牛区" }, - { "value": "4242", "label": "锦江区" }, - { "value": "4243", "label": "龙泉驿区" }, - { "value": "4244", "label": "青白江区" }, - { "value": "4245", "label": "青羊区" }, - { "value": "4246", "label": "温江区" }, - { "value": "4247", "label": "武侯区" }, - { "value": "4248", "label": "新都区" }, - { "value": "510185", "label": "其它区" } - ] - }, { - "value": "3091", - "label": "自贡", - "children": [ - { "value": "3092", "label": "自贡市" }, - { "value": "3093", "label": "荣县" }, - { "value": "3094", "label": "富顺县" }, - { "value": "4278", "label": "大安区" }, - { "value": "4279", "label": "贡井区" }, - { "value": "4280", "label": "沿滩区" }, - { "value": "4281", "label": "自流井区" }, - { "value": "510323", "label": "其它区" } - ] - }, { - "value": "3095", - "label": "攀枝花", - "children": [ - { "value": "3096", "label": "攀枝花市" }, - { "value": "3097", "label": "米易县" }, - { "value": "3098", "label": "盐边县" }, - { "value": "4270", "label": "东区" }, - { "value": "4271", "label": "仁和区" }, - { "value": "4272", "label": "西区" }, - { "value": "510423", "label": "其它区" } - ] - }, { - "value": "3099", - "label": "泸州", - "children": [ - { "value": "3100", "label": "泸州市" }, - { "value": "3101", "label": "泸县" }, - { "value": "3102", "label": "合江县" }, - { "value": "3103", "label": "叙永县" }, - { "value": "3104", "label": "古蔺县" }, - { "value": "4259", "label": "江阳区" }, - { "value": "4260", "label": "龙马潭区" }, - { "value": "4261", "label": "纳溪区" }, - { "value": "510526", "label": "其它区" } - ] - }, { - "value": "3105", - "label": "德阳", - "children": [ - { "value": "3106", "label": "德阳市" }, - { "value": "3107", "label": "广汉市" }, - { "value": "3108", "label": "什邡市" }, - { "value": "3109", "label": "绵竹市" }, - { "value": "3110", "label": "罗江县" }, - { "value": "3111", "label": "中江县" }, - { "value": "4250", "label": "旌阳区" }, - { "value": "510684", "label": "其它区" } - ] - }, { - "value": "3112", - "label": "绵阳", - "children": [ - { "value": "3113", "label": "绵阳市" }, - { "value": "3114", "label": "江油市" }, - { "value": "3115", "label": "盐亭县" }, - { "value": "3116", "label": "三台县" }, - { "value": "3117", "label": "平武县" }, - { "value": "3118", "label": "北川羌族自治县" }, - { "value": "3119", "label": "安县" }, - { "value": "3120", "label": "梓潼县" }, - { "value": "4263", "label": "涪城区" }, - { "value": "4264", "label": "游仙区" }, - { "value": "510782", "label": "其它区" } - ] - }, { - "value": "3121", - "label": "广元", - "children": [ - { "value": "3122", "label": "广元市" }, - { "value": "3123", "label": "青川县" }, - { "value": "3124", "label": "旺苍县" }, - { "value": "3125", "label": "剑阁县" }, - { "value": "3126", "label": "苍溪县" }, - { "value": "4252", "label": "朝天区" }, - { "value": "4253", "label": "市中区" }, - { "value": "4254", "label": "元坝区" }, - { "value": "510802", "label": "利州区" }, - { "value": "510825", "label": "其它区" } - ] - }, { - "value": "3127", - "label": "遂宁", - "children": [ - { "value": "3128", "label": "遂宁市" }, - { "value": "3129", "label": "射洪县" }, - { "value": "3131", "label": "大英县" }, - { "value": "3130", "label": "蓬溪县" }, - { "value": "4273", "label": "安居区" }, - { "value": "4274", "label": "船山区" }, - { "value": "510924", "label": "其它区" } - ] - }, { - "value": "3132", - "label": "内江", - "children": [ - { "value": "3133", "label": "内江市" }, - { "value": "3134", "label": "资中县" }, - { "value": "3135", "label": "隆昌县" }, - { "value": "3136", "label": "威远县" }, - { "value": "4265", "label": "东兴区" }, - { "value": "4266", "label": "市中区" }, - { "value": "511029", "label": "其它区" } - ] - }, { - "value": "3137", - "label": "乐山", - "children": [ - { "value": "3138", "label": "乐山市" }, - { "value": "3139", "label": "峨眉山市" }, - { "value": "3140", "label": "夹江县" }, - { "value": "3141", "label": "井研县" }, - { "value": "3142", "label": "犍为县" }, - { "value": "3144", "label": "马边彝族自治县" }, - { "value": "3145", "label": "峨边彝族自治县" }, - { "value": "3143", "label": "沐川县" }, - { "value": "4255", "label": "金口河区" }, - { "value": "4256", "label": "沙湾区" }, - { "value": "4257", "label": "市中区" }, - { "value": "4258", "label": "五通桥区" }, - { "value": "511182", "label": "其它区" } - ] - }, { - "value": "3146", - "label": "南充", - "children": [ - { "value": "3147", "label": "南充市" }, - { "value": "3148", "label": "阆中市" }, - { "value": "3149", "label": "营山县" }, - { "value": "3150", "label": "蓬安县" }, - { "value": "3151", "label": "仪陇县" }, - { "value": "3152", "label": "南部县" }, - { "value": "3153", "label": "西充县" }, - { "value": "4267", "label": "高坪区" }, - { "value": "4268", "label": "嘉陵区" }, - { "value": "4269", "label": "顺庆区" }, - { "value": "511382", "label": "其它区" } - ] - }, { - "value": "3154", - "label": "宜宾", - "children": [ - { "value": "3155", "label": "宜宾市" }, - { "value": "3156", "label": "宜宾县" }, - { "value": "3157", "label": "兴文县" }, - { "value": "3158", "label": "南溪县" }, - { "value": "3159", "label": "珙县" }, - { "value": "3160", "label": "长宁县" }, - { "value": "3161", "label": "高县" }, - { "value": "3162", "label": "江安县" }, - { "value": "3163", "label": "筠连县" }, - { "value": "3164", "label": "屏山县" }, - { "value": "4276", "label": "翠屏区" }, - { "value": "511530", "label": "其它区" } - ] - }, { - "value": "3165", - "label": "广安", - "children": [ - { "value": "3166", "label": "广安市" }, - { "value": "3167", "label": "华蓥市" }, - { "value": "3168", "label": "岳池县" }, - { "value": "3169", "label": "邻水县" }, - { "value": "3170", "label": "武胜县" }, - { "value": "4251", "label": "广安区" }, - { "value": "511682", "label": "市辖区" }, - { "value": "511683", "label": "其它区" } - ] - }, { - "value": "3171", - "label": "达州", - "children": [ - { "value": "3172", "label": "达州市" }, - { "value": "3173", "label": "万源市" }, - { "value": "3174", "label": "达县" }, - { "value": "3175", "label": "渠县" }, - { "value": "3176", "label": "宣汉县" }, - { "value": "3177", "label": "开江县" }, - { "value": "3178", "label": "大竹县" }, - { "value": "4249", "label": "通川区" }, - { "value": "511782", "label": "其它区" } - ] - }, { - "value": "3179", - "label": "巴中", - "children": [ - { "value": "3180", "label": "巴中市" }, - { "value": "3181", "label": "南江县" }, - { "value": "3182", "label": "平昌县" }, - { "value": "3183", "label": "通江县" }, - { "value": "4239", "label": "巴州区" }, - { "value": "511924", "label": "其它区" } - ] - }, { - "value": "3184", - "label": "雅安", - "children": [ - { "value": "3185", "label": "雅安市" }, - { "value": "3186", "label": "芦山县" }, - { "value": "3187", "label": "石棉县" }, - { "value": "3188", "label": "名山县" }, - { "value": "3189", "label": "天全县" }, - { "value": "3190", "label": "荥经县" }, - { "value": "3191", "label": "宝兴县" }, - { "value": "3192", "label": "汉源县" }, - { "value": "4275", "label": "雨城区" }, - { "value": "511828", "label": "其它区" } - ] - }, { - "value": "3193", - "label": "眉山", - "children": [ - { "value": "3194", "label": "眉山市" }, - { "value": "3195", "label": "仁寿县" }, - { "value": "3197", "label": "洪雅县" }, - { "value": "3198", "label": "丹棱县" }, - { "value": "3199", "label": "青神县" }, - { "value": "3196", "label": "彭山县" }, - { "value": "4262", "label": "东坡区" }, - { "value": "511426", "label": "其它区" } - ] - }, { - "value": "3200", - "label": "资阳", - "children": [ - { "value": "3201", "label": "资阳市" }, - { "value": "3202", "label": "简阳市" }, - { "value": "3203", "label": "安岳县" }, - { "value": "3204", "label": "乐至县" }, - { "value": "4277", "label": "雁江区" }, - { "value": "512082", "label": "其它区" } - ] - }, { - "value": "3205", - "label": "阿坝藏族羌族自治州", - "children": [ - { "value": "3206", "label": "马尔康县" }, - { "value": "3207", "label": "九寨沟县" }, - { "value": "3208", "label": "红原县" }, - { "value": "3209", "label": "汶川县" }, - { "value": "3211", "label": "理县" }, - { "value": "3212", "label": "若尔盖县" }, - { "value": "3213", "label": "小金县" }, - { "value": "3214", "label": "黑水县" }, - { "value": "3215", "label": "金川县" }, - { "value": "3216", "label": "松潘县" }, - { "value": "3217", "label": "壤塘县" }, - { "value": "3218", "label": "茂县" }, - { "value": "3210", "label": "阿坝县" } - ] - }, { - "value": "3219", - "label": "甘孜藏族自治州", - "children": [ - { "value": "3220", "label": "康定县" }, - { "value": "3221", "label": "丹巴县" }, - { "value": "3222", "label": "炉霍县" }, - { "value": "3223", "label": "九龙县" }, - { "value": "3224", "label": "甘孜县" }, - { "value": "3225", "label": "雅江县" }, - { "value": "3226", "label": "新龙县" }, - { "value": "3227", "label": "道孚县" }, - { "value": "3228", "label": "白玉县" }, - { "value": "3229", "label": "理塘县" }, - { "value": "3230", "label": "德格县" }, - { "value": "3231", "label": "乡城县" }, - { "value": "3232", "label": "石渠县" }, - { "value": "3233", "label": "稻城县" }, - { "value": "3234", "label": "色达县" }, - { "value": "3235", "label": "巴塘县" }, - { "value": "3236", "label": "泸定县" }, - { "value": "3237", "label": "得荣县" } - ] - }, { - "value": "3238", - "label": "凉山彝族自治州", - "children": [ - { "value": "3239", "label": "西昌市" }, - { "value": "3240", "label": "美姑县" }, - { "value": "3241", "label": "昭觉县" }, - { "value": "3242", "label": "金阳县" }, - { "value": "3243", "label": "甘洛县" }, - { "value": "3244", "label": "布拖县" }, - { "value": "3245", "label": "雷波县" }, - { "value": "3246", "label": "普格县" }, - { "value": "3247", "label": "宁南县" }, - { "value": "3248", "label": "喜德县" }, - { "value": "3249", "label": "会东县" }, - { "value": "3250", "label": "越西县" }, - { "value": "3251", "label": "会理县" }, - { "value": "3252", "label": "盐源县" }, - { "value": "3253", "label": "德昌县" }, - { "value": "3254", "label": "冕宁县" }, - { "value": "3255", "label": "木里藏族自治县" } - ] - }], - "value": "3078", - "label": "四川" - }, { - "children": [{ - "value": "3257", - "label": "天津", - "children": [ - { "value": "3258", "label": "天津市" }, - { "value": "3259", "label": "静海县" }, - { "value": "3260", "label": "宁河县" }, - { "value": "3261", "label": "蓟县" }, - { "value": "4282", "label": "宝坻区" }, - { "value": "4283", "label": "北辰区" }, - { "value": "4284", "label": "大港区" }, - { "value": "4285", "label": "东丽区" }, - { "value": "4286", "label": "汉沽区" }, - { "value": "4287", "label": "和平区" }, - { "value": "4288", "label": "河北区" }, - { "value": "4289", "label": "河东区" }, - { "value": "4290", "label": "河西区" }, - { "value": "4291", "label": "红桥区" }, - { "value": "4292", "label": "津南区" }, - { "value": "4293", "label": "南开区" }, - { "value": "4294", "label": "塘沽区" }, - { "value": "4295", "label": "武清区" }, - { "value": "4296", "label": "西青区" }, - { "value": "10005", "label": "滨海新区" }, - { "value": "120226", "label": "其它区" } - ] - }], - "value": "3256", - "label": "天津" - }, { - "children": [{ - "value": "3291", - "label": "拉萨", - "children": [ - { "value": "3292", "label": "拉萨市" }, - { "value": "3293", "label": "林周县" }, - { "value": "3294", "label": "达孜县" }, - { "value": "3295", "label": "尼木县" }, - { "value": "3296", "label": "当雄县" }, - { "value": "3297", "label": "曲水县" }, - { "value": "3298", "label": "墨竹工卡县" }, - { "value": "3299", "label": "堆龙德庆县" }, - { "value": "4297", "label": "城关区" }, - { "value": "540128", "label": "其它区" } - ] - }, { - "value": "3300", - "label": "那曲", - "children": [ - { "value": "3301", "label": "那曲县" }, - { "value": "3302", "label": "嘉黎县" }, - { "value": "3303", "label": "申扎县" }, - { "value": "3304", "label": "巴青县" }, - { "value": "3305", "label": "聂荣县" }, - { "value": "3306", "label": "尼玛县" }, - { "value": "3307", "label": "比如县" }, - { "value": "3308", "label": "索县" }, - { "value": "3309", "label": "班戈县" }, - { "value": "3310", "label": "安多县" } - ] - }, { - "value": "3311", - "label": "昌都", - "children": [ - { "value": "3312", "label": "昌都县" }, - { "value": "3313", "label": "芒康县" }, - { "value": "3314", "label": "贡觉县" }, - { "value": "3315", "label": "八宿县" }, - { "value": "3316", "label": "左贡县" }, - { "value": "3317", "label": "边坝县" }, - { "value": "3318", "label": "洛隆县" }, - { "value": "3319", "label": "江达县" }, - { "value": "3320", "label": "类乌齐县" }, - { "value": "3321", "label": "丁青县" }, - { "value": "3322", "label": "察雅县" } - ] - }, { - "value": "3323", - "label": "山南", - "children": [ - { "value": "3324", "label": "乃东县" }, - { "value": "3325", "label": "琼结县" }, - { "value": "3326", "label": "措美县" }, - { "value": "3327", "label": "加查县" }, - { "value": "3328", "label": "贡嘎县" }, - { "value": "3329", "label": "洛扎县" }, - { "value": "3330", "label": "曲松县" }, - { "value": "3331", "label": "桑日县" }, - { "value": "3332", "label": "扎囊县" }, - { "value": "3333", "label": "错那县" }, - { "value": "3335", "label": "浪卡子县" }, - { "value": "3334", "label": "隆子县" } - ] - }, { - "value": "3336", - "label": "日喀则", - "children": [ - { "value": "3337", "label": "日喀则市" }, - { "value": "3338", "label": "定结县" }, - { "value": "3339", "label": "萨迦县" }, - { "value": "3340", "label": "江孜县" }, - { "value": "3341", "label": "拉孜县" }, - { "value": "3342", "label": "定日县" }, - { "value": "3343", "label": "康马县" }, - { "value": "3344", "label": "聂拉木县" }, - { "value": "3345", "label": "吉隆县" }, - { "value": "3347", "label": "谢通门县" }, - { "value": "3348", "label": "昂仁县" }, - { "value": "3349", "label": "岗巴县" }, - { "value": "3350", "label": "仲巴县" }, - { "value": "3351", "label": "萨嘎县" }, - { "value": "3352", "label": "仁布县" }, - { "value": "3353", "label": "白朗县" }, - { "value": "3354", "label": "南木林县" }, - { "value": "3346", "label": "亚东县" } - ] - }, { - "value": "3355", - "label": "阿里", - "children": [ - { "value": "3356", "label": "噶尔县" }, - { "value": "3357", "label": "措勤县" }, - { "value": "3358", "label": "普兰县" }, - { "value": "3359", "label": "革吉县" }, - { "value": "3360", "label": "日土县" }, - { "value": "3361", "label": "札达县" }, - { "value": "3362", "label": "改则县" } - ] - }, { - "value": "3363", - "label": "林芝", - "children": [ - { "value": "3364", "label": "林芝县" }, - { "value": "3365", "label": "墨脱县" }, - { "value": "3366", "label": "朗县" }, - { "value": "3367", "label": "米林县" }, - { "value": "3368", "label": "察隅县" }, - { "value": "3369", "label": "波密县" }, - { "value": "3370", "label": "工布江达县" } - ] - }], - "value": "3290", - "label": "西藏" - }, { - "children": [{ - "value": "3372", - "label": "乌鲁木齐", - "children": [ - { "value": "3373", "label": "乌鲁木齐市" }, - { "value": "3374", "label": "乌鲁木齐县" }, - { "value": "4302", "label": "达坂城区" }, - { "value": "4303", "label": "东山区" }, - { "value": "4304", "label": "沙依巴克区" }, - { "value": "4305", "label": "水磨沟区" }, - { "value": "4306", "label": "天山区" }, - { "value": "4307", "label": "头屯河区" }, - { "value": "4308", "label": "新市区" }, - { "value": "650109", "label": "米东区" }, - { "value": "650122", "label": "其它区" } - ] - }, { - "value": "3375", - "label": "克拉玛依", - "children": [ - { "value": "3376", "label": "克拉玛依市" }, - { "value": "4298", "label": "白碱滩区" }, - { "value": "4299", "label": "独山子区" }, - { "value": "4300", "label": "克拉玛依区" }, - { "value": "4301", "label": "乌尔禾区" }, - { "value": "650206", "label": "其它区" } - ] - }, { - "value": "3377", - "label": "石河子", - "children": [ - { "value": "3378", "label": "石河子市" } - ] - }, { - "value": "3379", - "label": "阿拉尔", - "children": [ - { "value": "3380", "label": "阿拉尔市" } - ] - }, { - "value": "3381", - "label": "图木舒克", - "children": [ - { "value": "3382", "label": "图木舒克市" } - ] - }, { - "value": "3383", - "label": "五家渠", - "children": [ - { "value": "3384", "label": "五家渠市" } - ] - }, { - "value": "3385", - "label": "吐鲁番", - "children": [ - { "value": "3386", "label": "吐鲁番市" }, - { "value": "3387", "label": "托克逊县" }, - { "value": "3388", "label": "鄯善县" } - ] - }, { - "value": "3389", - "label": "哈密", - "children": [ - { "value": "3390", "label": "哈密市" }, - { "value": "3391", "label": "伊吾县" }, - { "value": "3392", "label": "巴里坤哈萨克自治县" } - ] - }, { - "value": "3393", - "label": "和田", - "children": [ - { "value": "3394", "label": "和田市" }, - { "value": "3395", "label": "和田县" }, - { "value": "3396", "label": "洛浦县" }, - { "value": "3397", "label": "民丰县" }, - { "value": "3398", "label": "皮山县" }, - { "value": "3399", "label": "策勒县" }, - { "value": "3401", "label": "墨玉县" }, - { "value": "3400", "label": "于田县" } - ] - }, { - "value": "3402", - "label": "阿克苏", - "children": [ - { "value": "3403", "label": "阿克苏市" }, - { "value": "3404", "label": "温宿县" }, - { "value": "3405", "label": "沙雅县" }, - { "value": "3406", "label": "拜城县" }, - { "value": "3407", "label": "阿瓦提县" }, - { "value": "3408", "label": "库车县" }, - { "value": "3409", "label": "柯坪县" }, - { "value": "3410", "label": "新和县" }, - { "value": "3411", "label": "乌什县" } - ] - }, { - "value": "3412", - "label": "喀什", - "children": [ - { "value": "3413", "label": "喀什市" }, - { "value": "3414", "label": "巴楚县" }, - { "value": "3415", "label": "泽普县" }, - { "value": "3416", "label": "伽师县" }, - { "value": "3417", "label": "叶城县" }, - { "value": "3418", "label": "岳普湖县" }, - { "value": "3419", "label": "疏勒县" }, - { "value": "3420", "label": "麦盖提县" }, - { "value": "3421", "label": "英吉沙县" }, - { "value": "3422", "label": "莎车县" }, - { "value": "3423", "label": "疏附县" }, - { "value": "3424", "label": "塔什库尔干塔吉克自治县" } - ] - }, { - "value": "3425", - "label": "克孜勒苏柯尔克孜自治州", - "children": [ - { "value": "3426", "label": "阿图什市" }, - { "value": "3427", "label": "阿合奇县" }, - { "value": "3428", "label": "乌恰县" }, - { "value": "3429", "label": "阿克陶县" } - ] - }, { - "value": "3430", - "label": "巴音郭楞蒙古自治州", - "children": [ - { "value": "3431", "label": "库尔勒市" }, - { "value": "3432", "label": "和静县" }, - { "value": "3433", "label": "尉犁县" }, - { "value": "3434", "label": "和硕县" }, - { "value": "3435", "label": "且末县" }, - { "value": "3436", "label": "博湖县" }, - { "value": "3437", "label": "轮台县" }, - { "value": "3438", "label": "若羌县" }, - { "value": "3439", "label": "焉耆回族自治县" } - ] - }, { - "value": "3440", - "label": "昌吉回族自治州", - "children": [ - { "value": "3441", "label": "昌吉市" }, - { "value": "3442", "label": "阜康市" }, - { "value": "3443", "label": "米泉市" }, - { "value": "3444", "label": "奇台县" }, - { "value": "3445", "label": "玛纳斯县" }, - { "value": "3446", "label": "吉木萨尔县" }, - { "value": "3447", "label": "呼图壁县" }, - { "value": "3448", "label": "木垒哈萨克自治县" } - ] - }, { - "value": "3449", - "label": "博尔塔拉蒙古自治州", - "children": [ - { "value": "3450", "label": "博乐市" }, - { "value": "3451", "label": "精河县" }, - { "value": "3452", "label": "温泉县" } - ] - }, { - "value": "3453", - "label": "伊犁哈萨克自治州", - "children": [ - { "value": "3455", "label": "奎屯市" }, - { "value": "3456", "label": "伊宁县" }, - { "value": "3457", "label": "特克斯县" }, - { "value": "3458", "label": "尼勒克县" }, - { "value": "3459", "label": "昭苏县" }, - { "value": "3460", "label": "新源县" }, - { "value": "3461", "label": "霍城县" }, - { "value": "3462", "label": "巩留县" }, - { "value": "3463", "label": "察布查尔锡伯自治县" }, - { "value": "3454", "label": "伊宁市" } - ] - }, { - "value": "3781", - "label": "阿勒泰地区", - "children": [ - { "value": "3471", "label": "阿勒泰市" }, - { "value": "3472", "label": "青河县" }, - { "value": "3473", "label": "吉木乃县" }, - { "value": "3474", "label": "富蕴县" }, - { "value": "3475", "label": "布尔津县" }, - { "value": "3476", "label": "福海县" }, - { "value": "3477", "label": "哈巴河县" } - ] - }, { - "value": "3782", - "label": "塔城地区", - "children": [ - { "value": "3464", "label": "塔城市" }, - { "value": "3465", "label": "乌苏市" }, - { "value": "3466", "label": "额敏县" }, - { "value": "3468", "label": "沙湾县" }, - { "value": "3469", "label": "托里县" }, - { "value": "3470", "label": "和布克赛尔蒙古自治县" }, - { "value": "3467", "label": "裕民县" } - ] - }], - "value": "3371", - "label": "新疆" - }, { - "children": [{ - "value": "3479", - "label": "杭州", - "children": [ - { "value": "3480", "label": "杭州市" }, - { "value": "3481", "label": "建德市" }, - { "value": "3482", "label": "富阳市" }, - { "value": "3483", "label": "临安市" }, - { "value": "3484", "label": "桐庐县" }, - { "value": "3485", "label": "淳安县" }, - { "value": "4319", "label": "滨江区" }, - { "value": "4320", "label": "拱墅区" }, - { "value": "4321", "label": "江干区" }, - { "value": "4322", "label": "上城区" }, - { "value": "4323", "label": "西湖区" }, - { "value": "4324", "label": "下城区" }, - { "value": "4325", "label": "萧山区" }, - { "value": "4326", "label": "余杭区" }, - { "value": "330186", "label": "其它区" } - ] - }, { - "value": "3486", - "label": "宁波", - "children": [ - { "value": "3487", "label": "宁波市" }, - { "value": "3488", "label": "余姚市" }, - { "value": "3489", "label": "慈溪市" }, - { "value": "3490", "label": "奉化市" }, - { "value": "3491", "label": "宁海县" }, - { "value": "3492", "label": "象山县" }, - { "value": "4334", "label": "北仑区" }, - { "value": "4335", "label": "海曙区" }, - { "value": "4336", "label": "江北区" }, - { "value": "4337", "label": "江东区" }, - { "value": "4338", "label": "鄞州区" }, - { "value": "4339", "label": "镇海区" }, - { "value": "330284", "label": "其它区" } - ] - }, { - "value": "3493", - "label": "温州", - "children": [ - { "value": "3494", "label": "温州市" }, - { "value": "3495", "label": "瑞安市" }, - { "value": "3496", "label": "乐清市" }, - { "value": "3497", "label": "永嘉县" }, - { "value": "3498", "label": "洞头县" }, - { "value": "3499", "label": "平阳县" }, - { "value": "3500", "label": "苍南县" }, - { "value": "3501", "label": "文成县" }, - { "value": "3502", "label": "泰顺县" }, - { "value": "4346", "label": "龙湾区" }, - { "value": "4347", "label": "鹿城区" }, - { "value": "4348", "label": "瓯海区" }, - { "value": "330383", "label": "其它区" } - ] - }, { - "value": "3503", - "label": "嘉兴", - "children": [ - { "value": "3504", "label": "嘉兴市" }, - { "value": "3505", "label": "海宁市" }, - { "value": "3506", "label": "平湖市" }, - { "value": "3507", "label": "桐乡市" }, - { "value": "3508", "label": "嘉善县" }, - { "value": "3509", "label": "海盐县" }, - { "value": "4329", "label": "秀城区" }, - { "value": "4330", "label": "秀洲区" }, - { "value": "330402", "label": "南湖区" }, - { "value": "330484", "label": "其它区" } - ] - }, { - "value": "3510", - "label": "湖州", - "children": [ - { "value": "3511", "label": "湖州市" }, - { "value": "3512", "label": "长兴县" }, - { "value": "3513", "label": "德清县" }, - { "value": "3514", "label": "安吉县" }, - { "value": "4327", "label": "南浔区" }, - { "value": "4328", "label": "吴兴区" }, - { "value": "330524", "label": "其它区" } - ] - }, { - "value": "3515", - "label": "绍兴", - "children": [ - { "value": "3516", "label": "绍兴市" }, - { "value": "3517", "label": "诸暨市" }, - { "value": "3519", "label": "嵊州市" }, - { "value": "3520", "label": "绍兴县" }, - { "value": "3521", "label": "新昌县" }, - { "value": "3518", "label": "上虞市" }, - { "value": "4342", "label": "越城区" }, - { "value": "330684", "label": "其它区" } - ] - }, { - "value": "3522", - "label": "金华", - "children": [ - { "value": "3523", "label": "金华市" }, - { "value": "3524", "label": "兰溪市" }, - { "value": "3525", "label": "义乌市" }, - { "value": "3526", "label": "东阳市" }, - { "value": "3527", "label": "永康市" }, - { "value": "3528", "label": "武义县" }, - { "value": "3529", "label": "浦江县" }, - { "value": "3530", "label": "磐安县" }, - { "value": "4331", "label": "金东区" }, - { "value": "4332", "label": "婺城区" }, - { "value": "330785", "label": "其它区" } - ] - }, { - "value": "3531", - "label": "衢州", - "children": [ - { "value": "3532", "label": "衢州市" }, - { "value": "3533", "label": "江山市" }, - { "value": "3534", "label": "龙游县" }, - { "value": "3535", "label": "常山县" }, - { "value": "3536", "label": "开化县" }, - { "value": "4340", "label": "柯城区" }, - { "value": "4341", "label": "衢江区" }, - { "value": "330882", "label": "其它区" } - ] - }, { - "value": "3537", - "label": "舟山", - "children": [ - { "value": "3538", "label": "舟山市" }, - { "value": "3539", "label": "岱山县" }, - { "value": "3540", "label": "嵊泗县" }, - { "value": "4349", "label": "定海区" }, - { "value": "4350", "label": "普陀区" }, - { "value": "330923", "label": "其它区" } - ] - }, { - "value": "3541", - "label": "台州", - "children": [ - { "value": "3542", "label": "台州市" }, - { "value": "3544", "label": "温岭市" }, - { "value": "3545", "label": "玉环县" }, - { "value": "3546", "label": "天台县" }, - { "value": "3547", "label": "仙居县" }, - { "value": "3548", "label": "三门县" }, - { "value": "3543", "label": "临海市" }, - { "value": "4343", "label": "黄岩区" }, - { "value": "4344", "label": "椒江区" }, - { "value": "4345", "label": "路桥区" }, - { "value": "331083", "label": "其它区" } - ] - }, { - "value": "3549", - "label": "丽水", - "children": [ - { "value": "3550", "label": "丽水市" }, - { "value": "3551", "label": "龙泉市" }, - { "value": "3552", "label": "缙云县" }, - { "value": "3553", "label": "青田县" }, - { "value": "3554", "label": "云和县" }, - { "value": "3555", "label": "遂昌县" }, - { "value": "3556", "label": "松阳县" }, - { "value": "3557", "label": "庆元县" }, - { "value": "3558", "label": "景宁畲族自治县" }, - { "value": "4333", "label": "莲都区" }, - { "value": "331182", "label": "其它区" } - ] - }], - "value": "3478", - "label": "浙江" - }, { - "children": [{ - "value": "3560", - "label": "昆明", - "children": [ - { "value": "3561", "label": "昆明市" }, - { "value": "3562", "label": "安宁市" }, - { "value": "3563", "label": "富民县" }, - { "value": "3564", "label": "嵩明县" }, - { "value": "3565", "label": "呈贡县" }, - { "value": "3566", "label": "晋宁县" }, - { "value": "3567", "label": "宜良县" }, - { "value": "3568", "label": "禄劝彝族苗族自治县" }, - { "value": "3569", "label": "石林彝族自治县" }, - { "value": "3570", "label": "寻甸回族自治县" }, - { "value": "4310", "label": "东川区" }, - { "value": "4311", "label": "官渡区" }, - { "value": "4312", "label": "盘龙区" }, - { "value": "4313", "label": "五华区" }, - { "value": "4314", "label": "西山区" }, - { "value": "530129", "label": "寻甸回族彝族自治县" }, - { "value": "530182", "label": "其它区" } - ] - }, { - "value": "3571", - "label": "曲靖", - "children": [ - { "value": "3572", "label": "曲靖市" }, - { "value": "3573", "label": "宣威市" }, - { "value": "3574", "label": "陆良县" }, - { "value": "3575", "label": "会泽县" }, - { "value": "3576", "label": "富源县" }, - { "value": "3577", "label": "罗平县" }, - { "value": "3578", "label": "马龙县" }, - { "value": "3579", "label": "师宗县" }, - { "value": "3580", "label": "沾益县" }, - { "value": "4316", "label": "麒麟区" }, - { "value": "530382", "label": "其它区" } - ] - }, { - "value": "3581", - "label": "玉溪", - "children": [ - { "value": "3582", "label": "玉溪市" }, - { "value": "3583", "label": "华宁县" }, - { "value": "3584", "label": "澄江县" }, - { "value": "3585", "label": "易门县" }, - { "value": "3586", "label": "通海县" }, - { "value": "3587", "label": "江川县" }, - { "value": "3588", "label": "元江哈尼族彝族傣族自治县" }, - { "value": "3589", "label": "新平彝族傣族自治县" }, - { "value": "3590", "label": "峨山彝族自治县" }, - { "value": "4317", "label": "红塔区" }, - { "value": "530429", "label": "其它区" } - ] - }, { - "value": "3591", - "label": "保山", - "children": [ - { "value": "3592", "label": "保山市" }, - { "value": "3593", "label": "施甸县" }, - { "value": "3595", "label": "龙陵县" }, - { "value": "3596", "label": "腾冲县" }, - { "value": "3594", "label": "昌宁县" }, - { "value": "4309", "label": "隆阳区" }, - { "value": "530525", "label": "其它区" } - ] - }, { - "value": "3597", - "label": "昭通", - "children": [ - { "value": "3598", "label": "昭通市" }, - { "value": "3599", "label": "永善县" }, - { "value": "3600", "label": "绥江县" }, - { "value": "3601", "label": "镇雄县" }, - { "value": "3602", "label": "大关县" }, - { "value": "3603", "label": "盐津县" }, - { "value": "3604", "label": "巧家县" }, - { "value": "3605", "label": "彝良县" }, - { "value": "3607", "label": "水富县" }, - { "value": "3608", "label": "鲁甸县" }, - { "value": "3606", "label": "威信县" }, - { "value": "4318", "label": "昭阳区" }, - { "value": "530631", "label": "其它区" } - ] - }, { - "value": "3609", - "label": "普洱", - "children": [ - { "value": "3610", "label": "普洱市" }, - { "value": "3611", "label": "宁洱哈尼族彝族自治县" }, - { "value": "3612", "label": "景东彝族自治县" }, - { "value": "3613", "label": "镇沅彝族哈尼族拉祜族自治县" }, - { "value": "3614", "label": "景谷傣族彝族自治县" }, - { "value": "3615", "label": "墨江哈尼族自治县" }, - { "value": "3616", "label": "澜沧拉祜族自治县" }, - { "value": "3617", "label": "西盟佤族自治县" }, - { "value": "3618", "label": "江城哈尼族彝族自治县" }, - { "value": "3619", "label": "孟连傣族拉祜族佤族自治县" }, - { "value": "4845", "label": "思茅区" }, - { "value": "530830", "label": "其它区" } - ] - }, { - "value": "3620", - "label": "临沧", - "children": [ - { "value": "3622", "label": "镇康县" }, - { "value": "3623", "label": "凤庆县" }, - { "value": "3624", "label": "云县" }, - { "value": "3625", "label": "永德县" }, - { "value": "3626", "label": "双江拉祜族佤族布朗族傣族自治县" }, - { "value": "3627", "label": "沧源佤族自治县" }, - { "value": "3628", "label": "耿马傣族佤族自治县" }, - { "value": "3783", "label": "临沧市" }, - { "value": "3621", "label": "临翔区" }, - { "value": "530928", "label": "其它区" } - ] - }, { - "value": "3629", - "label": "丽江", - "children": [ - { "value": "3630", "label": "丽江市" }, - { "value": "3631", "label": "玉龙纳西族自治县" }, - { "value": "3632", "label": "华坪县" }, - { "value": "3633", "label": "永胜县" }, - { "value": "3634", "label": "宁蒗彝族自治县" }, - { "value": "4315", "label": "古城区" }, - { "value": "530725", "label": "其它区" } - ] - }, { - "value": "3635", - "label": "文山壮族苗族自治州", - "children": [ - { "value": "3636", "label": "文山县" }, - { "value": "3637", "label": "麻栗坡县" }, - { "value": "3638", "label": "砚山县" }, - { "value": "3639", "label": "广南县" }, - { "value": "3640", "label": "马关县" }, - { "value": "3641", "label": "富宁县" }, - { "value": "3642", "label": "西畴县" }, - { "value": "3643", "label": "丘北县" } - ] - }, { - "value": "3644", - "label": "红河哈尼族彝族自治州", - "children": [ - { "value": "3645", "label": "个旧市" }, - { "value": "3646", "label": "开远市" }, - { "value": "3647", "label": "弥勒县" }, - { "value": "3648", "label": "红河县" }, - { "value": "3649", "label": "绿春县" }, - { "value": "3650", "label": "蒙自县" }, - { "value": "3651", "label": "泸西县" }, - { "value": "3652", "label": "建水县" }, - { "value": "3653", "label": "元阳县" }, - { "value": "3654", "label": "石屏县" }, - { "value": "3656", "label": "河口瑶族自治县" }, - { "value": "3657", "label": "屏边苗族自治县" }, - { "value": "3655", "label": "金平苗族瑶族傣族自治县" } - ] - }, { - "value": "3658", - "label": "西双版纳傣族自治州", - "children": [ - { "value": "3659", "label": "景洪市" }, - { "value": "3660", "label": "勐海县" }, - { "value": "3661", "label": "勐腊县" } - ] - }, { - "value": "3662", - "label": "楚雄彝族自治州", - "children": [ - { "value": "3663", "label": "楚雄市" }, - { "value": "3664", "label": "元谋县" }, - { "value": "3665", "label": "南华县" }, - { "value": "3666", "label": "牟定县" }, - { "value": "3667", "label": "武定县" }, - { "value": "3668", "label": "大姚县" }, - { "value": "3669", "label": "双柏县" }, - { "value": "3670", "label": "禄丰县" }, - { "value": "3671", "label": "永仁县" }, - { "value": "3672", "label": "姚安县" } - ] - }, { - "value": "3673", - "label": "大理白族自治州", - "children": [ - { "value": "3674", "label": "大理市" }, - { "value": "3675", "label": "剑川县" }, - { "value": "3676", "label": "弥渡县" }, - { "value": "3677", "label": "云龙县" }, - { "value": "3678", "label": "洱源县" }, - { "value": "3679", "label": "鹤庆县" }, - { "value": "3680", "label": "祥云县" }, - { "value": "3681", "label": "宾川县" }, - { "value": "3682", "label": "永平县" }, - { "value": "3683", "label": "漾濞彝族自治县" }, - { "value": "3684", "label": "巍山彝族回族自治县" }, - { "value": "3685", "label": "南涧彝族自治县" } - ] - }, { - "value": "3686", - "label": "德宏傣族景颇族自治州", - "children": [ - { "value": "3687", "label": "潞西市" }, - { "value": "3688", "label": "瑞丽市" }, - { "value": "3689", "label": "盈江县" }, - { "value": "3690", "label": "梁河县" }, - { "value": "3691", "label": "陇川县" } - ] - }, { - "value": "3692", - "label": "怒江傈傈族自治州", - "children": [ - { "value": "3693", "label": "泸水县" }, - { "value": "3694", "label": "福贡县" }, - { "value": "3695", "label": "兰坪白族普米族自治县" }, - { "value": "3696", "label": "贡山独龙族怒族自治县" } - ] - }, { - "value": "3697", - "label": "迪庆藏族自治州", - "children": [ - { "value": "3698", "label": "香格里拉县" }, - { "value": "3699", "label": "德钦县" }, - { "value": "3700", "label": "维西傈僳族自治县" } - ] - }], - "value": "3559", - "label": "云南" - }, { - "children": [{ - "value": "1909", - "label": "武汉", - "children": [ - { "value": "1910", "label": "武汉市" }, - { "value": "4423", "label": "江岸区" }, - { "value": "4769", "label": "蔡甸区" }, - { "value": "4770", "label": "东西湖区" }, - { "value": "4771", "label": "汉南区" }, - { "value": "4772", "label": "汉阳区" }, - { "value": "4773", "label": "洪山区" }, - { "value": "4774", "label": "黄陂区" }, - { "value": "4775", "label": "江汉区" }, - { "value": "4776", "label": "江夏区" }, - { "value": "4777", "label": "硚口区" }, - { "value": "4778", "label": "青山区" }, - { "value": "4779", "label": "武昌区" }, - { "value": "4780", "label": "新洲区" }, - { "value": "420118", "label": "其它区" } - ] - }, { - "value": "1911", - "label": "黄石", - "children": [ - { "value": "1912", "label": "黄石市" }, - { "value": "1913", "label": "大冶市" }, - { "value": "1914", "label": "阳新县" }, - { "value": "4421", "label": "铁山区" }, - { "value": "4759", "label": "黄石港区" }, - { "value": "4760", "label": "西塞山区" }, - { "value": "4761", "label": "下陆区" }, - { "value": "420282", "label": "其它区" } - ] - }, { - "value": "1915", - "label": "襄樊", - "children": [ - { "value": "1920", "label": "南漳县" }, - { "value": "1916", "label": "襄樊市" }, - { "value": "1917", "label": "老河口市" }, - { "value": "1918", "label": "枣阳市" }, - { "value": "1919", "label": "宜城市" }, - { "value": "1921", "label": "谷城县" }, - { "value": "1922", "label": "保康县" }, - { "value": "4424", "label": "樊城区" }, - { "value": "4782", "label": "襄城区" }, - { "value": "4783", "label": "襄阳区" } - ] - }, { - "value": "1923", - "label": "十堰", - "children": [ - { "value": "1924", "label": "十堰市" }, - { "value": "1925", "label": "丹江口市" }, - { "value": "1926", "label": "郧县" }, - { "value": "1927", "label": "竹山县" }, - { "value": "1928", "label": "房县" }, - { "value": "1929", "label": "郧西县" }, - { "value": "1930", "label": "竹溪县" }, - { "value": "4422", "label": "张湾区" }, - { "value": "4767", "label": "茅箭区" }, - { "value": "420383", "label": "其它区" } - ] - }, { - "value": "1931", - "label": "荆州", - "children": [ - { "value": "1933", "label": "洪湖市" }, - { "value": "1932", "label": "荆州市" }, - { "value": "1934", "label": "石首市" }, - { "value": "1935", "label": "松滋市" }, - { "value": "1936", "label": "监利县" }, - { "value": "1937", "label": "公安县" }, - { "value": "1938", "label": "江陵县" }, - { "value": "4764", "label": "荆州区" }, - { "value": "4765", "label": "沙市区" }, - { "value": "421088", "label": "其它区" } - ] - }, { - "value": "1939", - "label": "宜昌", - "children": [ - { "value": "1940", "label": "宜昌市" }, - { "value": "1941", "label": "宜都市" }, - { "value": "1942", "label": "当阳市" }, - { "value": "1943", "label": "枝江市" }, - { "value": "1944", "label": "秭归县" }, - { "value": "1945", "label": "远安县" }, - { "value": "1946", "label": "兴山县" }, - { "value": "1947", "label": "五峰土家族自治县" }, - { "value": "1948", "label": "长阳土家族自治县" }, - { "value": "4785", "label": "点军区" }, - { "value": "4786", "label": "伍家岗区" }, - { "value": "4787", "label": "西陵区" }, - { "value": "4788", "label": "猇亭区" }, - { "value": "4789", "label": "夷陵区" }, - { "value": "420551", "label": "葛洲坝区" }, - { "value": "420584", "label": "其它区" } - ] - }, { - "value": "1949", - "label": "荆门", - "children": [ - { "value": "1950", "label": "荆门市" }, - { "value": "1951", "label": "钟祥市" }, - { "value": "1952", "label": "京山县" }, - { "value": "1953", "label": "沙洋县" }, - { "value": "4762", "label": "东宝区" }, - { "value": "4763", "label": "掇刀区" }, - { "value": "420882", "label": "其它区" } - ] - }, { - "value": "1954", - "label": "鄂州", - "children": [ - { "value": "1955", "label": "鄂州市" }, - { "value": "4755", "label": "鄂城区" }, - { "value": "4756", "label": "华容区" }, - { "value": "4757", "label": "梁子湖区" }, - { "value": "420705", "label": "其它区" } - ] - }, { - "value": "1956", - "label": "孝感", - "children": [ - { "value": "1957", "label": "孝感市" }, - { "value": "1958", "label": "应城市" }, - { "value": "1959", "label": "安陆市" }, - { "value": "1960", "label": "汉川市" }, - { "value": "1961", "label": "云梦县" }, - { "value": "1962", "label": "大悟县" }, - { "value": "1963", "label": "孝昌县" }, - { "value": "4784", "label": "孝南区" }, - { "value": "420985", "label": "其它区" } - ] - }, { - "value": "1964", - "label": "黄冈", - "children": [ - { "value": "1965", "label": "黄冈市" }, - { "value": "1966", "label": "麻城市" }, - { "value": "1967", "label": "武穴市" }, - { "value": "1968", "label": "红安县" }, - { "value": "1969", "label": "罗田县" }, - { "value": "1970", "label": "浠水县" }, - { "value": "1971", "label": "蕲春县" }, - { "value": "1972", "label": "黄梅县" }, - { "value": "1973", "label": "英山县" }, - { "value": "1974", "label": "团风县" }, - { "value": "4758", "label": "黄州区" }, - { "value": "421183", "label": "其它区" } - ] - }, { - "value": "1975", - "label": "咸宁", - "children": [ - { "value": "1976", "label": "咸宁市" }, - { "value": "1977", "label": "赤壁市" }, - { "value": "1978", "label": "嘉鱼县" }, - { "value": "1979", "label": "通山县" }, - { "value": "1980", "label": "崇阳县" }, - { "value": "1981", "label": "通城县" }, - { "value": "4781", "label": "咸安区" }, - { "value": "421282", "label": "温泉城区" }, - { "value": "421283", "label": "其它区" } - ] - }, { - "value": "1982", - "label": "随州", - "children": [ - { "value": "1983", "label": "随州市" }, - { "value": "1984", "label": "广水市" }, - { "value": "4768", "label": "曾都区" }, - { "value": "421321", "label": "随县" }, - { "value": "421382", "label": "其它区" } - ] - }, { - "value": "1985", - "label": "仙桃", - "children": [ - { "value": "1986", "label": "仙桃市" } - ] - }, { - "value": "1987", - "label": "天门", - "children": [ - { "value": "1988", "label": "天门市" } - ] - }, { - "value": "1989", - "label": "潜江", - "children": [ - { "value": "1990", "label": "潜江市" } - ] - }, { - "value": "1991", - "label": "神农架林区", - "children": [ - { "value": "1992", "label": "神农架林区" }, - { "value": "4766", "label": "神农架林区" } - ] - }, { - "value": "1993", - "label": "恩施土家族苗族自治州", - "children": [ - { "value": "1996", "label": "建始县" }, - { "value": "1994", "label": "恩施市" }, - { "value": "1995", "label": "利川市" }, - { "value": "1997", "label": "来凤县" }, - { "value": "1998", "label": "巴东县" }, - { "value": "1999", "label": "鹤峰县" }, - { "value": "2000", "label": "宣恩县" }, - { "value": "2001", "label": "咸丰县" } - ] - }], - "value": "1908", - "label": "湖北" - }, { - "children": [{ - "value": "2259", - "label": "南昌", - "children": [ - { "value": "2260", "label": "南昌市" }, - { "value": "2261", "label": "新建县" }, - { "value": "2262", "label": "南昌县" }, - { "value": "2263", "label": "进贤县" }, - { "value": "2264", "label": "安义县" }, - { "value": "4047", "label": "东湖区" }, - { "value": "4048", "label": "青山湖区" }, - { "value": "4049", "label": "青云谱区" }, - { "value": "4050", "label": "湾里区" }, - { "value": "4051", "label": "西湖区" }, - { "value": "360125", "label": "红谷滩新区" }, - { "value": "360127", "label": "昌北区" }, - { "value": "360128", "label": "其它区" } - ] - }, { - "value": "2265", - "label": "景德镇", - "children": [ - { "value": "2266", "label": "景德镇市" }, - { "value": "2267", "label": "乐平市" }, - { "value": "2268", "label": "浮梁县" }, - { "value": "4044", "label": "昌江区" }, - { "value": "4045", "label": "珠山区" }, - { "value": "360282", "label": "其它区" } - ] - }, { - "value": "2269", - "label": "萍乡", - "children": [ - { "value": "2271", "label": "莲花县" }, - { "value": "2270", "label": "萍乡市" }, - { "value": "2272", "label": "上栗县" }, - { "value": "2273", "label": "芦溪县" }, - { "value": "4052", "label": "安源区" }, - { "value": "4369", "label": "湘东区" }, - { "value": "360324", "label": "其它区" } - ] - }, { - "value": "2274", - "label": "新余", - "children": [ - { "value": "2275", "label": "新余市" }, - { "value": "2276", "label": "分宜县" }, - { "value": "4054", "label": "渝水区" }, - { "value": "360522", "label": "其它区" } - ] - }, { - "value": "2277", - "label": "九江", - "children": [ - { "value": "2278", "label": "九江市" }, - { "value": "2279", "label": "瑞昌市" }, - { "value": "2280", "label": "九江县" }, - { "value": "2281", "label": "星子县" }, - { "value": "2282", "label": "武宁县" }, - { "value": "2283", "label": "彭泽县" }, - { "value": "2284", "label": "永修县" }, - { "value": "2285", "label": "修水县" }, - { "value": "2286", "label": "湖口县" }, - { "value": "2287", "label": "德安县" }, - { "value": "2288", "label": "都昌县" }, - { "value": "4046", "label": "浔阳区" }, - { "value": "4368", "label": "庐山区" }, - { "value": "360482", "label": "其它区" } - ] - }, { - "value": "2289", - "label": "鹰潭", - "children": [ - { "value": "2290", "label": "鹰潭市" }, - { "value": "2291", "label": "贵溪市" }, - { "value": "2292", "label": "余江县" }, - { "value": "4056", "label": "月湖区" }, - { "value": "360682", "label": "其它区" } - ] - }, { - "value": "2293", - "label": "赣州", - "children": [ - { "value": "2294", "label": "赣州市" }, - { "value": "2295", "label": "瑞金市" }, - { "value": "2296", "label": "南康市" }, - { "value": "2297", "label": "石城县" }, - { "value": "2298", "label": "安远县" }, - { "value": "2299", "label": "赣县" }, - { "value": "2300", "label": "宁都县" }, - { "value": "2301", "label": "寻乌县" }, - { "value": "2302", "label": "兴国县" }, - { "value": "2303", "label": "定南县" }, - { "value": "2304", "label": "上犹县" }, - { "value": "2305", "label": "于都县" }, - { "value": "2306", "label": "龙南县" }, - { "value": "2307", "label": "崇义县" }, - { "value": "2308", "label": "信丰县" }, - { "value": "2309", "label": "全南县" }, - { "value": "2310", "label": "大余县" }, - { "value": "2311", "label": "会昌县" }, - { "value": "4041", "label": "章贡区" }, - { "value": "360751", "label": "黄金区" }, - { "value": "360783", "label": "其它区" } - ] - }, { - "value": "2312", - "label": "吉安", - "children": [ - { "value": "2314", "label": "井冈山市" }, - { "value": "2313", "label": "吉安市" }, - { "value": "2315", "label": "吉安县" }, - { "value": "2316", "label": "永丰县" }, - { "value": "2317", "label": "永新县" }, - { "value": "2318", "label": "新干县" }, - { "value": "2319", "label": "泰和县" }, - { "value": "2320", "label": "峡江县" }, - { "value": "2321", "label": "遂川县" }, - { "value": "2322", "label": "安福县" }, - { "value": "2323", "label": "吉水县" }, - { "value": "2324", "label": "万安县" }, - { "value": "4042", "label": "吉州区" }, - { "value": "4043", "label": "青原区" }, - { "value": "360882", "label": "其它区" } - ] - }, { - "value": "2325", - "label": "宜春", - "children": [ - { "value": "2327", "label": "丰城市" }, - { "value": "2326", "label": "宜春市" }, - { "value": "2328", "label": "樟树市" }, - { "value": "2329", "label": "高安市" }, - { "value": "2330", "label": "铜鼓县" }, - { "value": "2331", "label": "靖安县" }, - { "value": "2332", "label": "宜丰县" }, - { "value": "2333", "label": "奉新县" }, - { "value": "2334", "label": "万载县" }, - { "value": "2335", "label": "上高县" }, - { "value": "4055", "label": "袁州区" }, - { "value": "360984", "label": "其它区" } - ] - }, { - "value": "2336", - "label": "抚州", - "children": [ - { "value": "2340", "label": "金溪县" }, - { "value": "2337", "label": "抚州市" }, - { "value": "2338", "label": "南丰县" }, - { "value": "2339", "label": "乐安县" }, - { "value": "2341", "label": "南城县" }, - { "value": "2342", "label": "东乡县" }, - { "value": "2343", "label": "资溪县" }, - { "value": "2344", "label": "宜黄县" }, - { "value": "2345", "label": "广昌县" }, - { "value": "2346", "label": "黎川县" }, - { "value": "2347", "label": "崇仁县" }, - { "value": "4040", "label": "临川区" }, - { "value": "361031", "label": "其它区" } - ] - }, { - "value": "2348", - "label": "上饶", - "children": [ - { "value": "2349", "label": "上饶市" }, - { "value": "2350", "label": "德兴市" }, - { "value": "2351", "label": "上饶县" }, - { "value": "2352", "label": "广丰县" }, - { "value": "2353", "label": "鄱阳县" }, - { "value": "2354", "label": "婺源县" }, - { "value": "2355", "label": "铅山县" }, - { "value": "2356", "label": "余干县" }, - { "value": "2357", "label": "横峰县" }, - { "value": "2358", "label": "弋阳县" }, - { "value": "2359", "label": "玉山县" }, - { "value": "2360", "label": "万年县" }, - { "value": "4053", "label": "信州区" }, - { "value": "361182", "label": "其它区" } - ] - }], - "value": "2258", - "label": "江西" - }, { - "children": [{ - "value": "3263", - "label": "重庆", - "children": [ - { "value": "3264", "label": "重庆市" }, - { "value": "3269", "label": "綦江县" }, - { "value": "3270", "label": "潼南县" }, - { "value": "3271", "label": "荣昌县" }, - { "value": "3272", "label": "璧山县" }, - { "value": "3273", "label": "大足县" }, - { "value": "3275", "label": "梁平县" }, - { "value": "3276", "label": "城口县" }, - { "value": "3277", "label": "垫江县" }, - { "value": "3278", "label": "武隆县" }, - { "value": "3279", "label": "丰都县" }, - { "value": "3280", "label": "奉节县" }, - { "value": "3281", "label": "开县" }, - { "value": "3282", "label": "云阳县" }, - { "value": "3283", "label": "忠县" }, - { "value": "3284", "label": "巫溪县" }, - { "value": "3285", "label": "巫山县" }, - { "value": "3286", "label": "石柱土家族自治县" }, - { "value": "3287", "label": "秀山土家族苗族自治县" }, - { "value": "3288", "label": "酉阳土家族苗族自治县" }, - { "value": "3289", "label": "彭水苗族土家族自治县" }, - { "value": "3274", "label": "铜梁县" }, - { "value": "3265", "label": "永川区" }, - { "value": "3266", "label": "合川区" }, - { "value": "3267", "label": "江津区" }, - { "value": "3268", "label": "南川区" }, - { "value": "4351", "label": "巴南区" }, - { "value": "4352", "label": "北碚区" }, - { "value": "4353", "label": "长寿区" }, - { "value": "4354", "label": "大渡口区" }, - { "value": "4355", "label": "涪陵区" }, - { "value": "4356", "label": "江北区" }, - { "value": "4357", "label": "九龙坡区" }, - { "value": "4358", "label": "南岸区" }, - { "value": "4359", "label": "黔江区" }, - { "value": "4360", "label": "沙坪坝区" }, - { "value": "4361", "label": "双桥区" }, - { "value": "4362", "label": "万盛区" }, - { "value": "4363", "label": "万州区" }, - { "value": "4364", "label": "渝北区" }, - { "value": "4365", "label": "渝中区" }, - { "value": "10002", "label": "高新区" }, - { "value": "10003", "label": "北部新区" }, - { "value": "10004", "label": "经济技术开发区" }, - { "value": "500385", "label": "其它区" } - ] - }], - "value": "3262", - "label": "重庆" - }, { - "children": [{ - "value": "2537", - "label": "银川", - "children": [ - { "value": "2538", "label": "银川市" }, - { "value": "2539", "label": "永宁县" }, - { "value": "2540", "label": "贺兰县" }, - { "value": "2541", "label": "灵武市" }, - { "value": "4128", "label": "金凤区" }, - { "value": "4129", "label": "西夏区" }, - { "value": "4130", "label": "兴庆区" }, - { "value": "640182", "label": "其它区" } - ] - }, { - "value": "2542", - "label": "石嘴山", - "children": [ - { "value": "2543", "label": "石嘴山市" }, - { "value": "2544", "label": "平罗县" }, - { "value": "2546", "label": "惠农区" }, - { "value": "4126", "label": "大武口区" }, - { "value": "640222", "label": "其它区" } - ] - }, { - "value": "2547", - "label": "吴忠", - "children": [ - { "value": "2548", "label": "吴忠市" }, - { "value": "2549", "label": "青铜峡市" }, - { "value": "2550", "label": "同心县" }, - { "value": "2551", "label": "盐池县" }, - { "value": "4127", "label": "利通区" }, - { "value": "640303", "label": "红寺堡区" }, - { "value": "640382", "label": "其它区" } - ] - }, { - "value": "2552", - "label": "中卫", - "children": [ - { "value": "3702", "label": "中卫市" }, - { "value": "2553", "label": "中宁县" }, - { "value": "2556", "label": "海原县" }, - { "value": "4131", "label": "沙坡头区" }, - { "value": "640523", "label": "其它区" } - ] - }, { - "value": "2554", - "label": "固原", - "children": [ - { "value": "2555", "label": "固原市" }, - { "value": "2557", "label": "西吉县" }, - { "value": "2558", "label": "隆德县" }, - { "value": "2559", "label": "泾源县" }, - { "value": "2560", "label": "彭阳县" }, - { "value": "4125", "label": "原州区" }, - { "value": "640426", "label": "其它区" } - ] - }], - "value": "2536", - "label": "宁夏" - }, { - "children": [{ - "value": "2562", - "label": "西宁", - "children": [ - { "value": "2563", "label": "西宁市" }, - { "value": "2564", "label": "湟源县" }, - { "value": "2565", "label": "湟中县" }, - { "value": "2566", "label": "大通回族土族自治县" }, - { "value": "4132", "label": "城北区" }, - { "value": "4133", "label": "城东区" }, - { "value": "4134", "label": "城西区" }, - { "value": "4378", "label": "城中区" }, - { "value": "630124", "label": "其它区" } - ] - }, { - "value": "2567", - "label": "海东", - "children": [ - { "value": "2568", "label": "平安县" }, - { "value": "2569", "label": "乐都县" }, - { "value": "2570", "label": "民和回族土族自治县" }, - { "value": "2571", "label": "互助土族自治县" }, - { "value": "2572", "label": "化隆回族自治县" }, - { "value": "2573", "label": "循化撒拉族自治县" } - ] - }, { - "value": "2574", - "label": "海北藏族自治州", - "children": [ - { "value": "2575", "label": "海晏县" }, - { "value": "2576", "label": "祁连县" }, - { "value": "2577", "label": "刚察县" }, - { "value": "2578", "label": "门源回族自治县" } - ] - }, { - "value": "2579", - "label": "黄南藏族自治州", - "children": [ - { "value": "2580", "label": "同仁县" }, - { "value": "2581", "label": "泽库县" }, - { "value": "2582", "label": "尖扎县" }, - { "value": "2583", "label": "河南蒙古族自治县" } - ] - }, { - "value": "2584", - "label": "海南藏族自治州", - "children": [ - { "value": "2587", "label": "贵德县" }, - { "value": "2585", "label": "共和县" }, - { "value": "2586", "label": "同德县" }, - { "value": "2588", "label": "兴海县" }, - { "value": "2589", "label": "贵南县" } - ] - }, { - "value": "2590", - "label": "果洛藏族自治州", - "children": [ - { "value": "2591", "label": "玛沁县" }, - { "value": "2592", "label": "班玛县" }, - { "value": "2593", "label": "甘德县" }, - { "value": "2594", "label": "达日县" }, - { "value": "2595", "label": "久治县" }, - { "value": "2596", "label": "玛多县" } - ] - }, { - "value": "2597", - "label": "玉树藏族自治州", - "children": [ - { "value": "2599", "label": "杂多县" }, - { "value": "2598", "label": "玉树县" }, - { "value": "2600", "label": "称多县" }, - { "value": "2601", "label": "治多县" }, - { "value": "2602", "label": "囊谦县" }, - { "value": "2603", "label": "曲麻莱县" } - ] - }, { - "value": "2604", - "label": "海西蒙古族藏族自治州", - "children": [ - { "value": "2605", "label": "德令哈市" }, - { "value": "2606", "label": "格尔木市" }, - { "value": "2607", "label": "乌兰县" }, - { "value": "2608", "label": "天峻县" }, - { "value": "2609", "label": "都兰县" } - ] - }], - "value": "2561", - "label": "青海" - }, { - "children": [{ - "value": "2611", - "label": "上海", - "children": [ - { "value": "2612", "label": "上海市" }, - { "value": "2613", "label": "崇明县" }, - { "value": "4221", "label": "宝山区" }, - { "value": "4222", "label": "长宁区" }, - { "value": "4223", "label": "奉贤区" }, - { "value": "4224", "label": "虹口区" }, - { "value": "4225", "label": "黄浦区" }, - { "value": "4226", "label": "嘉定区" }, - { "value": "4227", "label": "金山区" }, - { "value": "4228", "label": "静安区" }, - { "value": "4229", "label": "卢湾区" }, - { "value": "4230", "label": "闵行区" }, - { "value": "4231", "label": "南汇区" }, - { "value": "4232", "label": "浦东新区" }, - { "value": "4233", "label": "普陀区" }, - { "value": "4234", "label": "青浦区" }, - { "value": "4235", "label": "松江区" }, - { "value": "4236", "label": "徐汇区" }, - { "value": "4237", "label": "杨浦区" }, - { "value": "4238", "label": "闸北区" }, - { "value": "310152", "label": "川沙区" }, - { "value": "310231", "label": "其它区" } - ] - }], - "value": "2610", - "label": "上海" - }, { - "children": [{ - "value": "2615", - "label": "广州", - "children": [ - { "value": "2616", "label": "广州市" }, - { "value": "2617", "label": "从化市" }, - { "value": "2618", "label": "增城市" }, - { "value": "4398", "label": "海珠区" }, - { "value": "4532", "label": "白云区" }, - { "value": "4533", "label": "番禺区" }, - { "value": "4534", "label": "花都区" }, - { "value": "4535", "label": "黄埔区" }, - { "value": "4536", "label": "荔湾区" }, - { "value": "4537", "label": "萝岗区" }, - { "value": "4538", "label": "南沙区" }, - { "value": "4539", "label": "天河区" }, - { "value": "4540", "label": "越秀区" }, - { "value": "440189", "label": "其它区" } - ] - }, { - "value": "2619", - "label": "深圳", - "children": [ - { "value": "2620", "label": "深圳市" }, - { "value": "4402", "label": "南山区" }, - { "value": "4558", "label": "宝安区" }, - { "value": "4559", "label": "福田区" }, - { "value": "4560", "label": "龙岗区" }, - { "value": "4561", "label": "罗湖区" }, - { "value": "4562", "label": "盐田区" }, - { "value": "440309", "label": "其它区" } - ] - }, { - "value": "2621", - "label": "珠海", - "children": [ - { "value": "2622", "label": "珠海市" }, - { "value": "4570", "label": "斗门区" }, - { "value": "4571", "label": "金湾区" }, - { "value": "4572", "label": "香洲区" }, - { "value": "440486", "label": "金唐区" }, - { "value": "440487", "label": "南湾区" }, - { "value": "440488", "label": "其它区" } - ] - }, { - "value": "2623", - "label": "汕头", - "children": [ - { "value": "2624", "label": "汕头市" }, - { "value": "2627", "label": "南澳县" }, - { "value": "4550", "label": "潮南区" }, - { "value": "4551", "label": "潮阳区" }, - { "value": "4552", "label": "澄海区" }, - { "value": "4553", "label": "濠江区" }, - { "value": "4554", "label": "金平区" }, - { "value": "4555", "label": "龙湖区" }, - { "value": "440524", "label": "其它区" } - ] - }, { - "value": "2628", - "label": "韶关", - "children": [ - { "value": "2629", "label": "韶关市" }, - { "value": "2630", "label": "乐昌市" }, - { "value": "2631", "label": "南雄市" }, - { "value": "2632", "label": "仁化县" }, - { "value": "2633", "label": "始兴县" }, - { "value": "2634", "label": "翁源县" }, - { "value": "2636", "label": "新丰县" }, - { "value": "2637", "label": "乳源瑶族自治县" }, - { "value": "2635", "label": "曲江区" }, - { "value": "4556", "label": "武江区" }, - { "value": "4557", "label": "浈江区" }, - { "value": "440283", "label": "其它区" } - ] - }, { - "value": "2638", - "label": "河源", - "children": [ - { "value": "2643", "label": "连平县" }, - { "value": "2639", "label": "河源市" }, - { "value": "2640", "label": "和平县" }, - { "value": "2641", "label": "龙川县" }, - { "value": "2642", "label": "紫金县" }, - { "value": "2644", "label": "东源县" }, - { "value": "4541", "label": "源城区" }, - { "value": "441626", "label": "其它区" } - ] - }, { - "value": "2645", - "label": "梅州", - "children": [ - { "value": "2646", "label": "梅州市" }, - { "value": "2647", "label": "兴宁市" }, - { "value": "2648", "label": "梅县" }, - { "value": "2649", "label": "蕉岭县" }, - { "value": "2650", "label": "大埔县" }, - { "value": "2651", "label": "丰顺县" }, - { "value": "2652", "label": "五华县" }, - { "value": "2653", "label": "平远县" }, - { "value": "4400", "label": "梅江区" }, - { "value": "441482", "label": "其它区" } - ] - }, { - "value": "2654", - "label": "惠州", - "children": [ - { "value": "2655", "label": "惠州市" }, - { "value": "2657", "label": "惠东县" }, - { "value": "2658", "label": "博罗县" }, - { "value": "2659", "label": "龙门县" }, - { "value": "4399", "label": "惠城区" }, - { "value": "4542", "label": "惠阳区" }, - { "value": "441325", "label": "其它区" } - ] - }, { - "value": "2660", - "label": "汕尾", - "children": [ - { "value": "2661", "label": "汕尾市" }, - { "value": "2662", "label": "陆丰市" }, - { "value": "2663", "label": "海丰县" }, - { "value": "2664", "label": "陆河县" }, - { "value": "4401", "label": "城区" }, - { "value": "441582", "label": "其它区" } - ] - }, { - "value": "2665", - "label": "东莞", - "children": [ - { "value": "2666", "label": "东莞市" } - ] - }, { - "value": "2667", - "label": "中山", - "children": [ - { "value": "2668", "label": "中山市" } - ] - }, { - "value": "2669", - "label": "江门", - "children": [ - { "value": "2670", "label": "江门市" }, - { "value": "2671", "label": "台山市" }, - { "value": "2672", "label": "开平市" }, - { "value": "2673", "label": "鹤山市" }, - { "value": "2674", "label": "恩平市" }, - { "value": "4543", "label": "江海区" }, - { "value": "4544", "label": "蓬江区" }, - { "value": "4545", "label": "新会区" }, - { "value": "440786", "label": "其它区" } - ] - }, { - "value": "2675", - "label": "佛山", - "children": [ - { "value": "2676", "label": "佛山市" }, - { "value": "4527", "label": "禅城区" }, - { "value": "4528", "label": "高明区" }, - { "value": "4529", "label": "南海区" }, - { "value": "4530", "label": "三水区" }, - { "value": "4531", "label": "顺德区" }, - { "value": "440609", "label": "其它区" } - ] - }, { - "value": "2677", - "label": "阳江", - "children": [ - { "value": "2678", "label": "阳江市" }, - { "value": "2679", "label": "阳春市" }, - { "value": "2680", "label": "阳西县" }, - { "value": "2681", "label": "阳东县" }, - { "value": "4563", "label": "江城区" }, - { "value": "441782", "label": "其它区" } - ] - }, { - "value": "2682", - "label": "湛江", - "children": [ - { "value": "2683", "label": "湛江市" }, - { "value": "2684", "label": "廉江市" }, - { "value": "2685", "label": "雷州市" }, - { "value": "2686", "label": "吴川市" }, - { "value": "2687", "label": "遂溪县" }, - { "value": "2688", "label": "徐闻县" }, - { "value": "4565", "label": "赤坎区" }, - { "value": "4566", "label": "麻章区" }, - { "value": "4567", "label": "坡头区" }, - { "value": "4568", "label": "霞山区" }, - { "value": "440884", "label": "其它区" } - ] - }, { - "value": "2689", - "label": "茂名", - "children": [ - { "value": "2690", "label": "茂名市" }, - { "value": "2691", "label": "高州市" }, - { "value": "2692", "label": "化州市" }, - { "value": "2693", "label": "信宜市" }, - { "value": "2694", "label": "电白县" }, - { "value": "4547", "label": "茂港区" }, - { "value": "4548", "label": "茂南区" }, - { "value": "440984", "label": "其它区" } - ] - }, { - "value": "2695", - "label": "肇庆", - "children": [ - { "value": "2696", "label": "肇庆市" }, - { "value": "2697", "label": "高要市" }, - { "value": "2698", "label": "四会市" }, - { "value": "2699", "label": "广宁县" }, - { "value": "2700", "label": "德庆县" }, - { "value": "2701", "label": "封开县" }, - { "value": "2702", "label": "怀集县" }, - { "value": "4403", "label": "鼎湖区" }, - { "value": "4569", "label": "端州区" }, - { "value": "441285", "label": "其它区" } - ] - }, { - "value": "2703", - "label": "清远", - "children": [ - { "value": "2704", "label": "清远市" }, - { "value": "2705", "label": "英德市" }, - { "value": "2706", "label": "连州市" }, - { "value": "2707", "label": "佛冈县" }, - { "value": "2708", "label": "阳山县" }, - { "value": "2709", "label": "清新县" }, - { "value": "2710", "label": "连山壮族瑶族自治县" }, - { "value": "2711", "label": "连南瑶族自治县" }, - { "value": "4549", "label": "清城区" }, - { "value": "441883", "label": "其它区" } - ] - }, { - "value": "2712", - "label": "潮州", - "children": [ - { "value": "2713", "label": "潮州市" }, - { "value": "2714", "label": "潮安县" }, - { "value": "2715", "label": "饶平县" }, - { "value": "4397", "label": "湘桥区" }, - { "value": "445185", "label": "枫溪区" }, - { "value": "445186", "label": "其它区" } - ] - }, { - "value": "2716", - "label": "揭阳", - "children": [ - { "value": "2717", "label": "揭阳市" }, - { "value": "2718", "label": "普宁市" }, - { "value": "2719", "label": "揭东县" }, - { "value": "2720", "label": "揭西县" }, - { "value": "2721", "label": "惠来县" }, - { "value": "4546", "label": "榕城区" }, - { "value": "445285", "label": "其它区" } - ] - }, { - "value": "2722", - "label": "云浮", - "children": [ - { "value": "2723", "label": "云浮市" }, - { "value": "2724", "label": "罗定市" }, - { "value": "2725", "label": "云安县" }, - { "value": "2726", "label": "新兴县" }, - { "value": "2727", "label": "郁南县" }, - { "value": "4564", "label": "云城区" }, - { "value": "445382", "label": "其它区" } - ] - }], - "value": "2614", - "label": "广东" - }, { - "children": [{ - "value": "2729", - "label": "太原", - "children": [ - { "value": "2734", "label": "娄烦县" }, - { "value": "2730", "label": "太原市" }, - { "value": "2731", "label": "古交市" }, - { "value": "2732", "label": "阳曲县" }, - { "value": "2733", "label": "清徐县" }, - { "value": "4189", "label": "尖草坪区" }, - { "value": "4190", "label": "晋源区" }, - { "value": "4191", "label": "万柏林区" }, - { "value": "4192", "label": "小店区" }, - { "value": "4193", "label": "杏花岭区" }, - { "value": "4386", "label": "迎泽区" }, - { "value": "140182", "label": "其它区" } - ] - }, { - "value": "2735", - "label": "大同", - "children": [ - { "value": "2736", "label": "大同市" }, - { "value": "2737", "label": "大同县" }, - { "value": "2738", "label": "天镇县" }, - { "value": "2739", "label": "灵丘县" }, - { "value": "2740", "label": "阳高县" }, - { "value": "2741", "label": "左云县" }, - { "value": "2742", "label": "广灵县" }, - { "value": "2743", "label": "浑源县" }, - { "value": "4180", "label": "城区" }, - { "value": "4181", "label": "矿区" }, - { "value": "4182", "label": "南郊区" }, - { "value": "4183", "label": "新荣区" }, - { "value": "140228", "label": "其它区" } - ] - }, { - "value": "2744", - "label": "阳泉", - "children": [ - { "value": "2745", "label": "阳泉市" }, - { "value": "2746", "label": "平定县" }, - { "value": "2747", "label": "盂县" }, - { "value": "4195", "label": "城区" }, - { "value": "4196", "label": "郊区" }, - { "value": "4197", "label": "矿区" }, - { "value": "140323", "label": "其它区" } - ] - }, { - "value": "2748", - "label": "长治", - "children": [ - { "value": "2749", "label": "长治市" }, - { "value": "2750", "label": "潞城市" }, - { "value": "2751", "label": "长治县" }, - { "value": "2752", "label": "长子县" }, - { "value": "2753", "label": "平顺县" }, - { "value": "2754", "label": "襄垣县" }, - { "value": "2755", "label": "沁源县" }, - { "value": "2756", "label": "屯留县" }, - { "value": "2757", "label": "黎城县" }, - { "value": "2758", "label": "武乡县" }, - { "value": "2759", "label": "沁县" }, - { "value": "2760", "label": "壶关县" }, - { "value": "4179", "label": "城区" }, - { "value": "4384", "label": "郊区" }, - { "value": "140485", "label": "其它区" } - ] - }, { - "value": "2761", - "label": "晋城", - "children": [ - { "value": "2762", "label": "晋城市" }, - { "value": "2763", "label": "高平市" }, - { "value": "2764", "label": "泽州县" }, - { "value": "2765", "label": "陵川县" }, - { "value": "2766", "label": "阳城县" }, - { "value": "2767", "label": "沁水县" }, - { "value": "4184", "label": "城区" }, - { "value": "140582", "label": "其它区" } - ] - }, { - "value": "2768", - "label": "朔州", - "children": [ - { "value": "2769", "label": "朔州市" }, - { "value": "2770", "label": "山阴县" }, - { "value": "2771", "label": "右玉县" }, - { "value": "2772", "label": "应县" }, - { "value": "2773", "label": "怀仁县" }, - { "value": "4187", "label": "平鲁区" }, - { "value": "4188", "label": "朔城区" }, - { "value": "140625", "label": "其它区" } - ] - }, { - "value": "2774", - "label": "晋中", - "children": [ - { "value": "2783", "label": "和顺县" }, - { "value": "2775", "label": "晋中市" }, - { "value": "2776", "label": "介休市" }, - { "value": "2777", "label": "昔阳县" }, - { "value": "2778", "label": "灵石县" }, - { "value": "2779", "label": "祁县" }, - { "value": "2780", "label": "左权县" }, - { "value": "2781", "label": "寿阳县" }, - { "value": "2782", "label": "太谷县" }, - { "value": "2784", "label": "平遥县" }, - { "value": "2785", "label": "榆社县" }, - { "value": "4185", "label": "榆次区" }, - { "value": "140782", "label": "其它区" } - ] - }, { - "value": "2786", - "label": "忻州", - "children": [ - { "value": "2795", "label": "静乐县" }, - { "value": "2787", "label": "忻州市" }, - { "value": "2788", "label": "原平市" }, - { "value": "2789", "label": "代县" }, - { "value": "2790", "label": "神池县" }, - { "value": "2791", "label": "五寨县" }, - { "value": "2792", "label": "五台县" }, - { "value": "2793", "label": "偏关县" }, - { "value": "2794", "label": "宁武县" }, - { "value": "2796", "label": "繁峙县" }, - { "value": "2797", "label": "河曲县" }, - { "value": "2798", "label": "保德县" }, - { "value": "2799", "label": "定襄县" }, - { "value": "2800", "label": "岢岚县" }, - { "value": "4194", "label": "忻府区" }, - { "value": "140982", "label": "其它区" } - ] - }, { - "value": "2801", - "label": "临汾", - "children": [ - { "value": "2808", "label": "大宁县" }, - { "value": "2802", "label": "临汾市" }, - { "value": "2803", "label": "侯马市" }, - { "value": "2804", "label": "霍州市" }, - { "value": "2805", "label": "汾西县" }, - { "value": "2806", "label": "吉县" }, - { "value": "2807", "label": "安泽县" }, - { "value": "2809", "label": "浮山县" }, - { "value": "2810", "label": "古县" }, - { "value": "2811", "label": "隰县" }, - { "value": "2812", "label": "襄汾县" }, - { "value": "2813", "label": "翼城县" }, - { "value": "2814", "label": "永和县" }, - { "value": "2815", "label": "乡宁县" }, - { "value": "2816", "label": "曲沃县" }, - { "value": "2817", "label": "洪洞县" }, - { "value": "2818", "label": "蒲县" }, - { "value": "4186", "label": "尧都区" }, - { "value": "141083", "label": "其它区" } - ] - }, { - "value": "2819", - "label": "运城", - "children": [ - { "value": "2820", "label": "运城市" }, - { "value": "2821", "label": "河津市" }, - { "value": "2822", "label": "永济市" }, - { "value": "2823", "label": "闻喜县" }, - { "value": "2824", "label": "新绛县" }, - { "value": "2825", "label": "平陆县" }, - { "value": "2826", "label": "垣曲县" }, - { "value": "2827", "label": "绛县" }, - { "value": "2828", "label": "稷山县" }, - { "value": "2829", "label": "芮城县" }, - { "value": "2830", "label": "夏县" }, - { "value": "2831", "label": "万荣县" }, - { "value": "2832", "label": "临猗县" }, - { "value": "4198", "label": "盐湖区" }, - { "value": "140883", "label": "其它区" } - ] - }, { - "value": "2833", - "label": "吕梁", - "children": [ - { "value": "3701", "label": "吕梁市" }, - { "value": "2835", "label": "孝义市" }, - { "value": "2836", "label": "汾阳市" }, - { "value": "2837", "label": "文水县" }, - { "value": "2838", "label": "中阳县" }, - { "value": "2839", "label": "兴县" }, - { "value": "2840", "label": "临县" }, - { "value": "2841", "label": "方山县" }, - { "value": "2842", "label": "柳林县" }, - { "value": "2843", "label": "岚县" }, - { "value": "2844", "label": "交口县" }, - { "value": "2845", "label": "交城县" }, - { "value": "2846", "label": "石楼县" }, - { "value": "4385", "label": "离石区" }, - { "value": "141183", "label": "其它区" } - ] - }], - "value": "2728", - "label": "山西" - }, { - "children": [{ - "value": "2848", - "label": "济南", - "children": [ - { "value": "2849", "label": "济南市" }, - { "value": "2850", "label": "章丘市" }, - { "value": "2851", "label": "平阴县" }, - { "value": "2852", "label": "济阳县" }, - { "value": "2853", "label": "商河县" }, - { "value": "4140", "label": "长清区" }, - { "value": "4141", "label": "槐荫区" }, - { "value": "4142", "label": "历城区" }, - { "value": "4143", "label": "市中区" }, - { "value": "4144", "label": "天桥区" }, - { "value": "4379", "label": "历下区" }, - { "value": "370182", "label": "其它区" } - ] - }, { - "value": "2854", - "label": "青岛", - "children": [ - { "value": "2855", "label": "青岛市" }, - { "value": "2856", "label": "胶南市" }, - { "value": "2857", "label": "胶州市" }, - { "value": "2858", "label": "平度市" }, - { "value": "2859", "label": "莱西市" }, - { "value": "2860", "label": "即墨市" }, - { "value": "4152", "label": "城阳区" }, - { "value": "4153", "label": "黄岛区" }, - { "value": "4154", "label": "崂山区" }, - { "value": "4155", "label": "李沧区" }, - { "value": "4156", "label": "市北区" }, - { "value": "4157", "label": "四方区" }, - { "value": "4381", "label": "市南区" }, - { "value": "370286", "label": "其它区" } - ] - }, { - "value": "2861", - "label": "淄博", - "children": [ - { "value": "2862", "label": "淄博市" }, - { "value": "2863", "label": "桓台县" }, - { "value": "2864", "label": "高青县" }, - { "value": "2865", "label": "沂源县" }, - { "value": "4174", "label": "博山区" }, - { "value": "4175", "label": "临淄区" }, - { "value": "4176", "label": "张店区" }, - { "value": "4177", "label": "周村区" }, - { "value": "4178", "label": "淄川区" }, - { "value": "370324", "label": "其它区" } - ] - }, { - "value": "2866", - "label": "枣庄", - "children": [ - { "value": "2867", "label": "枣庄市" }, - { "value": "2868", "label": "滕州市" }, - { "value": "4170", "label": "山亭区" }, - { "value": "4171", "label": "市中区" }, - { "value": "4172", "label": "薛城区" }, - { "value": "4173", "label": "峄城区" }, - { "value": "4383", "label": "台儿庄区" }, - { "value": "370482", "label": "其它区" } - ] - }, { - "value": "2869", - "label": "东营", - "children": [ - { "value": "2870", "label": "东营市" }, - { "value": "2871", "label": "垦利县" }, - { "value": "2872", "label": "广饶县" }, - { "value": "2873", "label": "利津县" }, - { "value": "4137", "label": "东营区" }, - { "value": "4138", "label": "河口区" }, - { "value": "370591", "label": "其它区" } - ] - }, { - "value": "2874", - "label": "潍坊", - "children": [ - { "value": "2875", "label": "潍坊市" }, - { "value": "2876", "label": "青州市" }, - { "value": "2877", "label": "诸城市" }, - { "value": "2878", "label": "寿光市" }, - { "value": "2879", "label": "安丘市" }, - { "value": "2880", "label": "高密市" }, - { "value": "2881", "label": "昌邑市" }, - { "value": "2882", "label": "昌乐县" }, - { "value": "2883", "label": "临朐县" }, - { "value": "4163", "label": "坊子区" }, - { "value": "4164", "label": "寒亭区" }, - { "value": "4165", "label": "潍城区" }, - { "value": "4382", "label": "奎文区" }, - { "value": "10011", "label": "开发区" }, - { "value": "370787", "label": "其它区" } - ] - }, { - "value": "2884", - "label": "烟台", - "children": [ - { "value": "2885", "label": "烟台市" }, - { "value": "2886", "label": "龙口市" }, - { "value": "2887", "label": "莱阳市" }, - { "value": "2888", "label": "莱州市" }, - { "value": "2889", "label": "招远市" }, - { "value": "2890", "label": "蓬莱市" }, - { "value": "2891", "label": "栖霞市" }, - { "value": "2892", "label": "海阳市" }, - { "value": "2893", "label": "长岛县" }, - { "value": "4166", "label": "福山区" }, - { "value": "4167", "label": "莱山区" }, - { "value": "4168", "label": "牟平区" }, - { "value": "4169", "label": "芝罘区" }, - { "value": "370688", "label": "其它区" } - ] - }, { - "value": "2894", - "label": "威海", - "children": [ - { "value": "2895", "label": "威海市" }, - { "value": "2896", "label": "乳山市" }, - { "value": "2897", "label": "文登市" }, - { "value": "2898", "label": "荣成市" }, - { "value": "4162", "label": "环翠区" }, - { "value": "371084", "label": "其它区" } - ] - }, { - "value": "2899", - "label": "济宁", - "children": [ - { "value": "2900", "label": "济宁市" }, - { "value": "2901", "label": "曲阜市" }, - { "value": "2902", "label": "兖州市" }, - { "value": "2903", "label": "邹城市" }, - { "value": "2904", "label": "鱼台县" }, - { "value": "2905", "label": "金乡县" }, - { "value": "2906", "label": "嘉祥县" }, - { "value": "2907", "label": "微山县" }, - { "value": "2908", "label": "汶上县" }, - { "value": "2909", "label": "泗水县" }, - { "value": "2910", "label": "梁山县" }, - { "value": "4145", "label": "任城区" }, - { "value": "4146", "label": "市中区" }, - { "value": "370884", "label": "其它区" } - ] - }, { - "value": "2911", - "label": "泰安", - "children": [ - { "value": "2912", "label": "泰安市" }, - { "value": "2913", "label": "新泰市" }, - { "value": "2914", "label": "肥城市" }, - { "value": "2915", "label": "宁阳县" }, - { "value": "2916", "label": "东平县" }, - { "value": "4160", "label": "岱岳区" }, - { "value": "4161", "label": "泰山区" }, - { "value": "370984", "label": "其它区" } - ] - }, { - "value": "2917", - "label": "日照", - "children": [ - { "value": "2918", "label": "日照市" }, - { "value": "2919", "label": "五莲县" }, - { "value": "2920", "label": "莒县" }, - { "value": "4158", "label": "东港区" }, - { "value": "4159", "label": "岚山区" }, - { "value": "371123", "label": "其它区" } - ] - }, { - "value": "2921", - "label": "莱芜", - "children": [ - { "value": "2922", "label": "莱芜市" }, - { "value": "4147", "label": "钢城区" }, - { "value": "4148", "label": "莱城区" }, - { "value": "371204", "label": "其它区" } - ] - }, { - "value": "2923", - "label": "德州", - "children": [ - { "value": "2929", "label": "齐河县" }, - { "value": "2924", "label": "德州市" }, - { "value": "2925", "label": "乐陵市" }, - { "value": "2926", "label": "禹城市" }, - { "value": "2927", "label": "陵县" }, - { "value": "2928", "label": "宁津县" }, - { "value": "2930", "label": "武城县" }, - { "value": "2931", "label": "庆云县" }, - { "value": "2932", "label": "平原县" }, - { "value": "2933", "label": "夏津县" }, - { "value": "2934", "label": "临邑县" }, - { "value": "4136", "label": "德城区" }, - { "value": "371483", "label": "其它区" } - ] - }, { - "value": "2935", - "label": "临沂", - "children": [ - { "value": "2939", "label": "沂水县" }, - { "value": "2940", "label": "苍山县" }, - { "value": "2942", "label": "平邑县" }, - { "value": "2943", "label": "莒南县" }, - { "value": "2944", "label": "蒙阴县" }, - { "value": "2945", "label": "临沭县" }, - { "value": "2941", "label": "费县" }, - { "value": "2936", "label": "临沂市" }, - { "value": "2937", "label": "沂南县" }, - { "value": "2938", "label": "郯城县" }, - { "value": "4150", "label": "兰山区" }, - { "value": "4151", "label": "罗庄区" }, - { "value": "4380", "label": "河东区" }, - { "value": "371330", "label": "其它区" } - ] - }, { - "value": "2946", - "label": "聊城", - "children": [ - { "value": "2947", "label": "聊城市" }, - { "value": "2948", "label": "临清市" }, - { "value": "2949", "label": "高唐县" }, - { "value": "2950", "label": "阳谷县" }, - { "value": "2951", "label": "茌平县" }, - { "value": "2952", "label": "莘县" }, - { "value": "2953", "label": "东阿县" }, - { "value": "2954", "label": "冠县" }, - { "value": "4149", "label": "东昌府区" }, - { "value": "371582", "label": "其它区" } - ] - }, { - "value": "2955", - "label": "滨州", - "children": [ - { "value": "2956", "label": "滨州市" }, - { "value": "2957", "label": "邹平县" }, - { "value": "2958", "label": "沾化县" }, - { "value": "2959", "label": "惠民县" }, - { "value": "2960", "label": "博兴县" }, - { "value": "2961", "label": "阳信县" }, - { "value": "2962", "label": "无棣县" }, - { "value": "4135", "label": "滨城区" }, - { "value": "10012", "label": "经济开发区" }, - { "value": "371627", "label": "其它区" } - ] - }, { - "value": "2963", - "label": "菏泽", - "children": [ - { "value": "2964", "label": "菏泽市" }, - { "value": "2965", "label": "鄄城县" }, - { "value": "2966", "label": "单县" }, - { "value": "2967", "label": "郓城县" }, - { "value": "2968", "label": "曹县" }, - { "value": "2969", "label": "定陶县" }, - { "value": "2970", "label": "巨野县" }, - { "value": "2971", "label": "东明县" }, - { "value": "2972", "label": "成武县" }, - { "value": "4139", "label": "牡丹区" }, - { "value": "371729", "label": "其它区" } - ] - }], - "value": "2847", - "label": "山东" - }, { - "children": [{ - "value": "1003", - "label": "合肥", - "children": [ - { "value": "1004", "label": "合肥市" }, - { "value": "1005", "label": "长丰县" }, - { "value": "1006", "label": "肥东县" }, - { "value": "1007", "label": "肥西县" }, - { "value": "4448", "label": "包河区" }, - { "value": "4449", "label": "庐阳区" }, - { "value": "4450", "label": "蜀山区" }, - { "value": "4451", "label": "瑶海区" }, - { "value": "340191", "label": "中区" }, - { "value": "340192", "label": "其它区" } - ] - }, { - "value": "1008", - "label": "芜湖", - "children": [ - { "value": "1009", "label": "芜湖市" }, - { "value": "1010", "label": "芜湖县" }, - { "value": "1011", "label": "南陵县" }, - { "value": "1012", "label": "繁昌县" }, - { "value": "4471", "label": "镜湖区" }, - { "value": "4472", "label": "鸠江区" }, - { "value": "4473", "label": "三山区" }, - { "value": "4474", "label": "弋江区" }, - { "value": "340224", "label": "其它区" } - ] - }, { - "value": "1013", - "label": "蚌埠", - "children": [ - { "value": "1014", "label": "蚌埠市" }, - { "value": "1015", "label": "怀远县" }, - { "value": "1016", "label": "固镇县" }, - { "value": "1017", "label": "五河县" }, - { "value": "4436", "label": "蚌山区" }, - { "value": "4437", "label": "淮上区" }, - { "value": "4438", "label": "龙子湖区" }, - { "value": "4439", "label": "禹会区" }, - { "value": "340324", "label": "其它区" } - ] - }, { - "value": "1018", - "label": "淮南", - "children": [ - { "value": "1019", "label": "淮南市" }, - { "value": "1020", "label": "凤台县" }, - { "value": "4455", "label": "八公山区" }, - { "value": "4456", "label": "大通区" }, - { "value": "4457", "label": "潘集区" }, - { "value": "4458", "label": "田家庵区" }, - { "value": "4459", "label": "谢家集区" }, - { "value": "340422", "label": "其它区" } - ] - }, { - "value": "1021", - "label": "马鞍山", - "children": [ - { "value": "1022", "label": "马鞍山市" }, - { "value": "1023", "label": "当涂县" }, - { "value": "4389", "label": "雨山区" }, - { "value": "4465", "label": "花山区" }, - { "value": "4466", "label": "金家庄区" }, - { "value": "340522", "label": "其它区" } - ] - }, { - "value": "1024", - "label": "淮北", - "children": [ - { "value": "1025", "label": "淮北市" }, - { "value": "1026", "label": "濉溪县" }, - { "value": "4452", "label": "杜集区" }, - { "value": "4453", "label": "烈山区" }, - { "value": "4454", "label": "相山区" }, - { "value": "340622", "label": "其它区" } - ] - }, { - "value": "1027", - "label": "铜陵", - "children": [ - { "value": "1028", "label": "铜陵市" }, - { "value": "1029", "label": "铜陵县" }, - { "value": "4468", "label": "郊区" }, - { "value": "4469", "label": "狮子山区" }, - { "value": "4470", "label": "铜官山区" }, - { "value": "340722", "label": "其它区" } - ] - }, { - "value": "1030", - "label": "安庆", - "children": [ - { "value": "1031", "label": "安庆市" }, - { "value": "1032", "label": "桐城市" }, - { "value": "1033", "label": "宿松县" }, - { "value": "1034", "label": "枞阳县" }, - { "value": "1035", "label": "太湖县" }, - { "value": "1036", "label": "怀宁县" }, - { "value": "1037", "label": "岳西县" }, - { "value": "1038", "label": "望江县" }, - { "value": "1039", "label": "潜山县" }, - { "value": "4433", "label": "大观区" }, - { "value": "4434", "label": "宜秀区" }, - { "value": "4435", "label": "迎江区" }, - { "value": "340882", "label": "其它区" } - ] - }, { - "value": "1040", - "label": "黄山", - "children": [ - { "value": "1043", "label": "歙县" }, - { "value": "1041", "label": "黄山市" }, - { "value": "1042", "label": "休宁县" }, - { "value": "1044", "label": "祁门县" }, - { "value": "1045", "label": "黟县" }, - { "value": "4460", "label": "黄山区" }, - { "value": "4461", "label": "徽州区" }, - { "value": "4462", "label": "屯溪区" }, - { "value": "341025", "label": "其它区" } - ] - }, { - "value": "1046", - "label": "滁州", - "children": [ - { "value": "1047", "label": "滁州市" }, - { "value": "1048", "label": "天长市" }, - { "value": "1049", "label": "明光市" }, - { "value": "1050", "label": "全椒县" }, - { "value": "1051", "label": "来安县" }, - { "value": "1052", "label": "定远县" }, - { "value": "1053", "label": "凤阳县" }, - { "value": "4443", "label": "琅琊区" }, - { "value": "4444", "label": "南谯区" }, - { "value": "341183", "label": "其它区" } - ] - }, { - "value": "1054", - "label": "阜阳", - "children": [ - { "value": "1055", "label": "阜阳市" }, - { "value": "1056", "label": "界首市" }, - { "value": "1057", "label": "临泉县" }, - { "value": "1058", "label": "颍上县" }, - { "value": "1059", "label": "阜南县" }, - { "value": "1060", "label": "太和县" }, - { "value": "4445", "label": "颍东区" }, - { "value": "4446", "label": "颍泉区" }, - { "value": "4447", "label": "颍州区" }, - { "value": "341283", "label": "其它区" } - ] - }, { - "value": "1061", - "label": "宿州", - "children": [ - { "value": "1066", "label": "灵璧县" }, - { "value": "1062", "label": "宿州市" }, - { "value": "1063", "label": "萧县" }, - { "value": "1064", "label": "泗县" }, - { "value": "1065", "label": "砀山县" }, - { "value": "4467", "label": "埇桥区" }, - { "value": "341325", "label": "其它区" } - ] - }, { - "value": "1067", - "label": "巢湖", - "children": [ - { "value": "1068", "label": "巢湖市" }, - { "value": "1069", "label": "含山县" }, - { "value": "1070", "label": "无为县" }, - { "value": "1071", "label": "庐江县" }, - { "value": "1072", "label": "和县" }, - { "value": "4441", "label": "居巢区" } - ] - }, { - "value": "1073", - "label": "六安", - "children": [ - { "value": "1074", "label": "六安市" }, - { "value": "1075", "label": "寿县" }, - { "value": "1076", "label": "霍山县" }, - { "value": "1077", "label": "霍邱县" }, - { "value": "1078", "label": "舒城县" }, - { "value": "1079", "label": "金寨县" }, - { "value": "4463", "label": "金安区" }, - { "value": "4464", "label": "裕安区" }, - { "value": "341526", "label": "其它区" } - ] - }, { - "value": "1080", - "label": "亳州", - "children": [ - { "value": "1081", "label": "亳州市" }, - { "value": "1082", "label": "利辛县" }, - { "value": "1083", "label": "涡阳县" }, - { "value": "1084", "label": "蒙城县" }, - { "value": "4440", "label": "谯城区" }, - { "value": "341624", "label": "其它区" } - ] - }, { - "value": "1085", - "label": "池州", - "children": [ - { "value": "1086", "label": "池州市" }, - { "value": "1087", "label": "东至县" }, - { "value": "1088", "label": "石台县" }, - { "value": "1089", "label": "青阳县" }, - { "value": "4442", "label": "贵池区" }, - { "value": "341724", "label": "其它区" } - ] - }, { - "value": "1090", - "label": "宣城", - "children": [ - { "value": "1091", "label": "宣城市" }, - { "value": "1092", "label": "宁国市" }, - { "value": "1093", "label": "广德县" }, - { "value": "1094", "label": "郎溪县" }, - { "value": "1095", "label": "泾县" }, - { "value": "1096", "label": "旌德县" }, - { "value": "1097", "label": "绩溪县" }, - { "value": "4475", "label": "宣州区" }, - { "value": "341882", "label": "其它区" } - ] - }], - "value": "1002", - "label": "安徽" - }, { - "children": [{ - "value": "1099", - "label": "北京", - "children": [ - { "value": "1100", "label": "北京市" }, - { "value": "1101", "label": "密云县" }, - { "value": "1102", "label": "延庆县" }, - { "value": "4390", "label": "昌平区" }, - { "value": "4391", "label": "怀柔区" }, - { "value": "4476", "label": "朝阳区" }, - { "value": "4477", "label": "崇文区" }, - { "value": "4478", "label": "大兴区" }, - { "value": "4479", "label": "东城区" }, - { "value": "4480", "label": "房山区" }, - { "value": "4481", "label": "丰台区" }, - { "value": "4482", "label": "海淀区" }, - { "value": "4483", "label": "门头沟区" }, - { "value": "4484", "label": "平谷区" }, - { "value": "4485", "label": "石景山区" }, - { "value": "4486", "label": "顺义区" }, - { "value": "4487", "label": "通州区" }, - { "value": "4488", "label": "西城区" }, - { "value": "4489", "label": "宣武区" }, - { "value": "110230", "label": "其它区" } - ] - }], - "value": "1098", - "label": "北京" - }, { - "children": [{ - "value": "1104", - "label": "福州", - "children": [ - { "value": "1107", "label": "长乐市" }, - { "value": "1105", "label": "福州市" }, - { "value": "1106", "label": "福清市" }, - { "value": "1108", "label": "闽侯县" }, - { "value": "1109", "label": "闽清县" }, - { "value": "1110", "label": "永泰县" }, - { "value": "1111", "label": "连江县" }, - { "value": "1112", "label": "罗源县" }, - { "value": "1113", "label": "平潭县" }, - { "value": "4392", "label": "鼓楼区" }, - { "value": "4490", "label": "仓山区" }, - { "value": "4491", "label": "晋安区" }, - { "value": "4492", "label": "马尾区" }, - { "value": "4493", "label": "台江区" }, - { "value": "350183", "label": "其它区" } - ] - }, { - "value": "1114", - "label": "厦门", - "children": [ - { "value": "1115", "label": "厦门市" }, - { "value": "4505", "label": "海沧区" }, - { "value": "4506", "label": "湖里区" }, - { "value": "4507", "label": "集美区" }, - { "value": "4508", "label": "思明区" }, - { "value": "4509", "label": "同安区" }, - { "value": "4510", "label": "翔安区" }, - { "value": "350214", "label": "其它区" } - ] - }, { - "value": "1116", - "label": "三明", - "children": [ - { "value": "1120", "label": "将乐县" }, - { "value": "1117", "label": "三明市" }, - { "value": "1118", "label": "永安市" }, - { "value": "1119", "label": "明溪县" }, - { "value": "1121", "label": "大田县" }, - { "value": "1122", "label": "宁化县" }, - { "value": "1123", "label": "建宁县" }, - { "value": "1124", "label": "沙县" }, - { "value": "1125", "label": "尤溪县" }, - { "value": "1126", "label": "清流县" }, - { "value": "1127", "label": "泰宁县" }, - { "value": "4394", "label": "三元区" }, - { "value": "4504", "label": "梅列区" }, - { "value": "350482", "label": "其它区" } - ] - }, { - "value": "1128", - "label": "莆田", - "children": [ - { "value": "1129", "label": "莆田市" }, - { "value": "1130", "label": "仙游县" }, - { "value": "4393", "label": "涵江区" }, - { "value": "4497", "label": "城厢区" }, - { "value": "4498", "label": "荔城区" }, - { "value": "4499", "label": "秀屿区" }, - { "value": "350323", "label": "其它区" } - ] - }, { - "value": "1131", - "label": "泉州", - "children": [ - { "value": "1132", "label": "泉州市" }, - { "value": "1133", "label": "石狮市" }, - { "value": "1134", "label": "晋江市" }, - { "value": "1135", "label": "南安市" }, - { "value": "1136", "label": "惠安县" }, - { "value": "1137", "label": "永春县" }, - { "value": "1138", "label": "安溪县" }, - { "value": "1139", "label": "德化县" }, - { "value": "1140", "label": "金门县" }, - { "value": "4500", "label": "丰泽区" }, - { "value": "4501", "label": "鲤城区" }, - { "value": "4502", "label": "洛江区" }, - { "value": "4503", "label": "泉港区" }, - { "value": "350584", "label": "其它区" } - ] - }, { - "value": "1141", - "label": "漳州", - "children": [ - { "value": "1142", "label": "漳州市" }, - { "value": "1143", "label": "龙海市" }, - { "value": "1144", "label": "平和县" }, - { "value": "1145", "label": "南靖县" }, - { "value": "1146", "label": "诏安县" }, - { "value": "1147", "label": "漳浦县" }, - { "value": "1148", "label": "华安县" }, - { "value": "1149", "label": "东山县" }, - { "value": "1150", "label": "长泰县" }, - { "value": "1151", "label": "云霄县" }, - { "value": "4511", "label": "龙文区" }, - { "value": "4512", "label": "芗城区" }, - { "value": "350682", "label": "其它区" } - ] - }, { - "value": "1152", - "label": "南平", - "children": [ - { "value": "1153", "label": "南平市" }, - { "value": "1154", "label": "建瓯市" }, - { "value": "1155", "label": "邵武市" }, - { "value": "1156", "label": "武夷山市" }, - { "value": "1157", "label": "建阳市" }, - { "value": "1158", "label": "松溪县" }, - { "value": "1159", "label": "光泽县" }, - { "value": "1160", "label": "顺昌县" }, - { "value": "1161", "label": "浦城县" }, - { "value": "1162", "label": "政和县" }, - { "value": "4495", "label": "延平区" }, - { "value": "350785", "label": "其它区" } - ] - }, { - "value": "1163", - "label": "龙岩", - "children": [ - { "value": "1164", "label": "龙岩市" }, - { "value": "1165", "label": "漳平市" }, - { "value": "1166", "label": "长汀县" }, - { "value": "1167", "label": "武平县" }, - { "value": "1168", "label": "上杭县" }, - { "value": "1169", "label": "永定县" }, - { "value": "1170", "label": "连城县" }, - { "value": "4494", "label": "新罗区" }, - { "value": "350882", "label": "其它区" } - ] - }, { - "value": "1171", - "label": "宁德", - "children": [ - { "value": "1173", "label": "福安市" }, - { "value": "1172", "label": "宁德市" }, - { "value": "1174", "label": "福鼎市" }, - { "value": "1175", "label": "寿宁县" }, - { "value": "1176", "label": "霞浦县" }, - { "value": "1177", "label": "柘荣县" }, - { "value": "1178", "label": "屏南县" }, - { "value": "1179", "label": "古田县" }, - { "value": "1180", "label": "周宁县" }, - { "value": "4496", "label": "蕉城区" }, - { "value": "350983", "label": "其它区" } - ] - }], - "value": "1103", - "label": "福建" - }, { - "children": [{ - "value": "1182", - "label": "兰州", - "children": [ - { "value": "1186", "label": "皋兰县" }, - { "value": "1183", "label": "兰州市" }, - { "value": "1184", "label": "永登县" }, - { "value": "1185", "label": "榆中县" }, - { "value": "4396", "label": "七里河区" }, - { "value": "4517", "label": "安宁区" }, - { "value": "4518", "label": "城关区" }, - { "value": "4519", "label": "红古区" }, - { "value": "4520", "label": "西固区" }, - { "value": "620124", "label": "其它区" } - ] - }, { - "value": "1187", - "label": "金昌", - "children": [ - { "value": "1188", "label": "金昌市" }, - { "value": "1189", "label": "永昌县" }, - { "value": "4515", "label": "金川区" }, - { "value": "620322", "label": "其它区" } - ] - }, { - "value": "1190", - "label": "白银", - "children": [ - { "value": "1191", "label": "白银市" }, - { "value": "1192", "label": "靖远县" }, - { "value": "1193", "label": "景泰县" }, - { "value": "1194", "label": "会宁县" }, - { "value": "4395", "label": "白银区" }, - { "value": "4513", "label": "平川区" }, - { "value": "620424", "label": "其它区" } - ] - }, { - "value": "1195", - "label": "天水", - "children": [ - { "value": "1198", "label": "甘谷县" }, - { "value": "1196", "label": "天水市" }, - { "value": "1197", "label": "武山县" }, - { "value": "1199", "label": "清水县" }, - { "value": "1200", "label": "秦安县" }, - { "value": "1201", "label": "张家川回族自治县" }, - { "value": "4523", "label": "北道区" }, - { "value": "4524", "label": "秦城区" }, - { "value": "620502", "label": "秦州区" }, - { "value": "620503", "label": "麦积区" }, - { "value": "620526", "label": "其它区" } - ] - }, { - "value": "1202", - "label": "嘉峪关", - "children": [ - { "value": "1203", "label": "嘉峪关市" } - ] - }, { - "value": "1204", - "label": "武威", - "children": [ - { "value": "1205", "label": "武威市" }, - { "value": "1206", "label": "民勤县" }, - { "value": "1207", "label": "古浪县" }, - { "value": "1208", "label": "天祝藏族自治县" }, - { "value": "4525", "label": "凉州区" }, - { "value": "620624", "label": "其它区" } - ] - }, { - "value": "1209", - "label": "张掖", - "children": [ - { "value": "1210", "label": "张掖市" }, - { "value": "1211", "label": "民乐县" }, - { "value": "1212", "label": "山丹县" }, - { "value": "1213", "label": "临泽县" }, - { "value": "1214", "label": "高台县" }, - { "value": "1215", "label": "肃南裕固族自治县" }, - { "value": "4526", "label": "甘州区" }, - { "value": "620726", "label": "其它区" } - ] - }, { - "value": "1216", - "label": "平凉", - "children": [ - { "value": "1217", "label": "平凉市" }, - { "value": "1218", "label": "灵台县" }, - { "value": "1219", "label": "静宁县" }, - { "value": "1220", "label": "崇信县" }, - { "value": "1221", "label": "华亭县" }, - { "value": "1222", "label": "泾川县" }, - { "value": "1223", "label": "庄浪县" }, - { "value": "4521", "label": "崆峒区" }, - { "value": "620827", "label": "其它区" } - ] - }, { - "value": "1224", - "label": "酒泉", - "children": [ - { "value": "1225", "label": "酒泉市" }, - { "value": "1226", "label": "玉门市" }, - { "value": "1227", "label": "敦煌市" }, - { "value": "1228", "label": "瓜州县" }, - { "value": "1229", "label": "金塔县" }, - { "value": "1230", "label": "阿克塞哈萨克族自治县" }, - { "value": "1231", "label": "肃北蒙古族自治县" }, - { "value": "4516", "label": "肃州区" }, - { "value": "620922", "label": "安西县" }, - { "value": "620983", "label": "其它区" } - ] - }, { - "value": "1232", - "label": "庆阳", - "children": [ - { "value": "1233", "label": "庆阳市" }, - { "value": "1234", "label": "庆城县" }, - { "value": "1235", "label": "镇原县" }, - { "value": "1236", "label": "合水县" }, - { "value": "1237", "label": "华池县" }, - { "value": "1238", "label": "环县" }, - { "value": "1239", "label": "宁县" }, - { "value": "1240", "label": "正宁县" }, - { "value": "4522", "label": "西峰区" }, - { "value": "621028", "label": "其它区" } - ] - }, { - "value": "1241", - "label": "定西", - "children": [ - { "value": "1247", "label": "漳县" }, - { "value": "1242", "label": "定西市" }, - { "value": "1243", "label": "岷县" }, - { "value": "1244", "label": "渭源县" }, - { "value": "1245", "label": "陇西县" }, - { "value": "1246", "label": "通渭县" }, - { "value": "1248", "label": "临洮县" }, - { "value": "4514", "label": "安定区" }, - { "value": "621127", "label": "其它区" } - ] - }, { - "value": "1249", - "label": "陇南", - "children": [ - { "value": "1253", "label": "武都区" }, - { "value": "3784", "label": "陇南市" }, - { "value": "1250", "label": "成县" }, - { "value": "1251", "label": "礼县" }, - { "value": "1252", "label": "康县" }, - { "value": "1254", "label": "文县" }, - { "value": "1255", "label": "两当县" }, - { "value": "1256", "label": "徽县" }, - { "value": "1257", "label": "宕昌县" }, - { "value": "1258", "label": "西和县" }, - { "value": "621229", "label": "其它区" } - ] - }, { - "value": "1259", - "label": "甘南藏族自治州", - "children": [ - { "value": "1260", "label": "合作市" }, - { "value": "1261", "label": "临潭县" }, - { "value": "1262", "label": "卓尼县" }, - { "value": "1263", "label": "舟曲县" }, - { "value": "1264", "label": "迭部县" }, - { "value": "1265", "label": "玛曲县" }, - { "value": "1266", "label": "碌曲县" }, - { "value": "1267", "label": "夏河县" } - ] - }, { - "value": "1268", - "label": "临夏回族自治州", - "children": [ - { "value": "1273", "label": "广河县" }, - { "value": "1269", "label": "临夏市" }, - { "value": "1270", "label": "临夏县" }, - { "value": "1271", "label": "康乐县" }, - { "value": "1272", "label": "永靖县" }, - { "value": "1274", "label": "和政县" }, - { "value": "1275", "label": "东乡族自治县" }, - { "value": "1276", "label": "积石山保安族东乡族撒拉族自治县" } - ] - }], - "value": "1181", - "label": "甘肃" - }, { - "children": [{ - "value": "1278", - "label": "南宁", - "children": [ - { "value": "1280", "label": "邕宁区" }, - { "value": "1279", "label": "南宁市" }, - { "value": "1281", "label": "武鸣县" }, - { "value": "1282", "label": "隆安县" }, - { "value": "1283", "label": "马山县" }, - { "value": "1284", "label": "上林县" }, - { "value": "1285", "label": "宾阳县" }, - { "value": "1286", "label": "横县" }, - { "value": "4407", "label": "钦南区" }, - { "value": "4592", "label": "江南区" }, - { "value": "4593", "label": "良庆区" }, - { "value": "4594", "label": "青秀区" }, - { "value": "4595", "label": "西乡塘区" }, - { "value": "4596", "label": "兴宁区" }, - { "value": "4597", "label": "钦北区" }, - { "value": "4598", "label": "长洲区" }, - { "value": "4599", "label": "蝶山区" }, - { "value": "4600", "label": "万秀区" }, - { "value": "4601", "label": "玉州区" }, - { "value": "450128", "label": "其它区" } - ] - }, { - "value": "1287", - "label": "柳州", - "children": [ - { "value": "1288", "label": "柳州市" }, - { "value": "1289", "label": "柳江县" }, - { "value": "1290", "label": "柳城县" }, - { "value": "1291", "label": "鹿寨县" }, - { "value": "1292", "label": "融安县" }, - { "value": "1293", "label": "融水苗族自治县" }, - { "value": "1294", "label": "三江侗族自治县" }, - { "value": "4406", "label": "柳南区" }, - { "value": "4589", "label": "城中区" }, - { "value": "4590", "label": "柳北区" }, - { "value": "4591", "label": "鱼峰区" }, - { "value": "450227", "label": "其它区" } - ] - }, { - "value": "1295", - "label": "桂林", - "children": [ - { "value": "1296", "label": "桂林市" }, - { "value": "1297", "label": "阳朔县" }, - { "value": "1298", "label": "临桂县" }, - { "value": "1299", "label": "灵川县" }, - { "value": "1300", "label": "全州县" }, - { "value": "1301", "label": "平乐县" }, - { "value": "1302", "label": "兴安县" }, - { "value": "1303", "label": "灌阳县" }, - { "value": "1304", "label": "荔蒲县" }, - { "value": "1305", "label": "资源县" }, - { "value": "1306", "label": "永福县" }, - { "value": "1307", "label": "龙胜各族自治县" }, - { "value": "1308", "label": "恭城瑶族自治县" }, - { "value": "4405", "label": "象山区" }, - { "value": "4582", "label": "叠彩区" }, - { "value": "4583", "label": "七星区" }, - { "value": "4584", "label": "秀峰区" }, - { "value": "4585", "label": "雁山区" }, - { "value": "450331", "label": "荔浦县" }, - { "value": "450333", "label": "其它区" } - ] - }, { - "value": "1309", - "label": "梧州", - "children": [ - { "value": "1313", "label": "藤县" }, - { "value": "1310", "label": "梧州市" }, - { "value": "1311", "label": "岑溪市" }, - { "value": "1312", "label": "苍梧县" }, - { "value": "1314", "label": "蒙山县" }, - { "value": "450482", "label": "其它区" } - ] - }, { - "value": "1315", - "label": "北海", - "children": [ - { "value": "1316", "label": "北海市" }, - { "value": "1317", "label": "合浦县" }, - { "value": "4574", "label": "海城区" }, - { "value": "4575", "label": "铁山港区" }, - { "value": "4576", "label": "银海区" }, - { "value": "450522", "label": "其它区" } - ] - }, { - "value": "1318", - "label": "防城港", - "children": [ - { "value": "1319", "label": "防城港市" }, - { "value": "1320", "label": "东兴市" }, - { "value": "1321", "label": "上思县" }, - { "value": "4577", "label": "防城区" }, - { "value": "4578", "label": "港口区" }, - { "value": "450682", "label": "其它区" } - ] - }, { - "value": "1322", - "label": "钦州", - "children": [ - { "value": "1325", "label": "浦北县" }, - { "value": "1323", "label": "钦州市" }, - { "value": "1324", "label": "灵山县" }, - { "value": "450723", "label": "其它区" } - ] - }, { - "value": "1326", - "label": "贵港", - "children": [ - { "value": "1327", "label": "贵港市" }, - { "value": "1328", "label": "桂平市" }, - { "value": "1329", "label": "平南县" }, - { "value": "4579", "label": "港北区" }, - { "value": "4580", "label": "港南区" }, - { "value": "4581", "label": "覃塘区" }, - { "value": "450882", "label": "其它区" } - ] - }, { - "value": "1330", - "label": "玉林", - "children": [ - { "value": "1331", "label": "玉林市" }, - { "value": "1332", "label": "北流市" }, - { "value": "1333", "label": "容县" }, - { "value": "1334", "label": "陆川县" }, - { "value": "1335", "label": "博白县" }, - { "value": "1336", "label": "兴业县" }, - { "value": "450982", "label": "其它区" } - ] - }, { - "value": "1337", - "label": "百色", - "children": [ - { "value": "1338", "label": "百色市" }, - { "value": "1339", "label": "凌云县" }, - { "value": "1340", "label": "平果县" }, - { "value": "1341", "label": "西林县" }, - { "value": "1342", "label": "乐业县" }, - { "value": "1343", "label": "德保县" }, - { "value": "1344", "label": "田林县" }, - { "value": "1345", "label": "田阳县" }, - { "value": "1346", "label": "靖西县" }, - { "value": "1347", "label": "田东县" }, - { "value": "1348", "label": "那坡县" }, - { "value": "1349", "label": "隆林各族自治县" }, - { "value": "4573", "label": "右江区" }, - { "value": "451032", "label": "其它区" } - ] - }, { - "value": "1350", - "label": "贺州", - "children": [ - { "value": "1351", "label": "贺州市" }, - { "value": "1352", "label": "钟山县" }, - { "value": "1353", "label": "昭平县" }, - { "value": "1354", "label": "富川瑶族自治县" }, - { "value": "4587", "label": "八步区" }, - { "value": "451124", "label": "其它区" } - ] - }, { - "value": "1355", - "label": "河池", - "children": [ - { "value": "1356", "label": "河池市" }, - { "value": "1357", "label": "宜州市" }, - { "value": "1358", "label": "天峨县" }, - { "value": "1359", "label": "凤山县" }, - { "value": "1360", "label": "南丹县" }, - { "value": "1361", "label": "东兰县" }, - { "value": "1362", "label": "都安瑶族自治县" }, - { "value": "1363", "label": "罗城仫佬族自治县" }, - { "value": "1364", "label": "巴马瑶族自治县" }, - { "value": "1365", "label": "环江毛南族自治县" }, - { "value": "1366", "label": "大化瑶族自治县" }, - { "value": "4586", "label": "金城江区" }, - { "value": "451282", "label": "其它区" } - ] - }, { - "value": "1367", - "label": "来宾", - "children": [ - { "value": "1368", "label": "来宾市" }, - { "value": "1369", "label": "合山市" }, - { "value": "1370", "label": "象州县" }, - { "value": "1371", "label": "武宣县" }, - { "value": "1372", "label": "忻城县" }, - { "value": "1373", "label": "金秀瑶族自治县" }, - { "value": "4588", "label": "兴宾区" }, - { "value": "451382", "label": "其它区" } - ] - }, { - "value": "1374", - "label": "崇左", - "children": [ - { "value": "1375", "label": "崇左市" }, - { "value": "1376", "label": "凭祥市" }, - { "value": "1377", "label": "扶绥县" }, - { "value": "1378", "label": "大新县" }, - { "value": "1379", "label": "天等县" }, - { "value": "1380", "label": "宁明县" }, - { "value": "1381", "label": "龙州县" }, - { "value": "4404", "label": "江洲区" }, - { "value": "451402", "label": "江州区" }, - { "value": "451482", "label": "其它区" } - ] - }], - "value": "1277", - "label": "广西" - }, { - "children": [{ - "value": "1383", - "label": "贵阳", - "children": [ - { "value": "1385", "label": "清镇市" }, - { "value": "1384", "label": "贵阳市" }, - { "value": "1386", "label": "开阳县" }, - { "value": "1387", "label": "修文县" }, - { "value": "1388", "label": "息烽县" }, - { "value": "4408", "label": "南明区" }, - { "value": "4603", "label": "白云区" }, - { "value": "4604", "label": "花溪区" }, - { "value": "4605", "label": "乌当区" }, - { "value": "4606", "label": "小河区" }, - { "value": "4607", "label": "云岩区" }, - { "value": "520151", "label": "金阳开发区" }, - { "value": "520182", "label": "其它区" } - ] - }, { - "value": "1389", - "label": "六盘水", - "children": [ - { "value": "1390", "label": "六盘水市" }, - { "value": "1391", "label": "水城县" }, - { "value": "1392", "label": "盘县" }, - { "value": "1393", "label": "六枝特区" }, - { "value": "4608", "label": "六枝特区" }, - { "value": "4609", "label": "钟山区" }, - { "value": "520223", "label": "其它区" } - ] - }, { - "value": "1394", - "label": "遵义", - "children": [ - { "value": "1397", "label": "仁怀市" }, - { "value": "1395", "label": "遵义市" }, - { "value": "1396", "label": "赤水市" }, - { "value": "1398", "label": "遵义县" }, - { "value": "1399", "label": "绥阳县" }, - { "value": "1400", "label": "桐梓县" }, - { "value": "1401", "label": "习水县" }, - { "value": "1402", "label": "凤冈县" }, - { "value": "1403", "label": "正安县" }, - { "value": "1404", "label": "余庆县" }, - { "value": "1405", "label": "湄潭县" }, - { "value": "1406", "label": "道真仡佬族苗族自治县" }, - { "value": "1407", "label": "务川仡佬族苗族自治县" }, - { "value": "4611", "label": "红花岗区" }, - { "value": "4612", "label": "汇川区" }, - { "value": "520383", "label": "其它区" } - ] - }, { - "value": "1408", - "label": "安顺", - "children": [ - { "value": "1409", "label": "安顺市" }, - { "value": "1410", "label": "普定县" }, - { "value": "1411", "label": "平坝县" }, - { "value": "1412", "label": "镇宁布依族苗族自治县" }, - { "value": "1413", "label": "紫云苗族布依族自治县" }, - { "value": "1414", "label": "关岭布依族苗族自治县" }, - { "value": "4602", "label": "西秀区" }, - { "value": "520426", "label": "其它区" } - ] - }, { - "value": "1415", - "label": "铜仁", - "children": [ - { "value": "1416", "label": "铜仁市" }, - { "value": "1417", "label": "德江县" }, - { "value": "1418", "label": "江口县" }, - { "value": "1419", "label": "思南县" }, - { "value": "1420", "label": "石阡县" }, - { "value": "1421", "label": "玉屏侗族自治县" }, - { "value": "1422", "label": "松桃苗族自治县" }, - { "value": "1423", "label": "印江土家族苗族自治县" }, - { "value": "1424", "label": "沿河土家族自治县" }, - { "value": "1425", "label": "万山特区" }, - { "value": "4610", "label": "万山特区" } - ] - }, { - "value": "1426", - "label": "毕节", - "children": [ - { "value": "1427", "label": "毕节市" }, - { "value": "1428", "label": "黔西县" }, - { "value": "1429", "label": "大方县" }, - { "value": "1430", "label": "织金县" }, - { "value": "1431", "label": "金沙县" }, - { "value": "1432", "label": "赫章县" }, - { "value": "1433", "label": "纳雍县" }, - { "value": "1434", "label": "威宁彝族回族苗族自治县" } - ] - }, { - "value": "1435", - "label": "黔西南布依族苗族自治州", - "children": [ - { "value": "1436", "label": "兴义市" }, - { "value": "1437", "label": "望谟县" }, - { "value": "1438", "label": "兴仁县" }, - { "value": "1439", "label": "普安县" }, - { "value": "1440", "label": "册亨县" }, - { "value": "1441", "label": "晴隆县" }, - { "value": "1442", "label": "贞丰县" }, - { "value": "1443", "label": "安龙县" } - ] - }, { - "value": "1444", - "label": "黔东南苗族侗族自治州", - "children": [ - { "value": "1460", "label": "丹寨县" }, - { "value": "1445", "label": "凯里市" }, - { "value": "1446", "label": "施秉县" }, - { "value": "1447", "label": "从江县" }, - { "value": "1448", "label": "锦屏县" }, - { "value": "1449", "label": "镇远县" }, - { "value": "1450", "label": "麻江县" }, - { "value": "1451", "label": "台江县" }, - { "value": "1452", "label": "天柱县" }, - { "value": "1453", "label": "黄平县" }, - { "value": "1454", "label": "榕江县" }, - { "value": "1455", "label": "剑河县" }, - { "value": "1456", "label": "三穗县" }, - { "value": "1457", "label": "雷山县" }, - { "value": "1458", "label": "黎平县" }, - { "value": "1459", "label": "岑巩县" } - ] - }, { - "value": "1461", - "label": "黔南布依族苗族自治州", - "children": [ - { "value": "1462", "label": "都匀市" }, - { "value": "1463", "label": "福泉市" }, - { "value": "1464", "label": "贵定县" }, - { "value": "1465", "label": "惠水县" }, - { "value": "1466", "label": "罗甸县" }, - { "value": "1467", "label": "瓮安县" }, - { "value": "1468", "label": "荔波县" }, - { "value": "1469", "label": "龙里县" }, - { "value": "1470", "label": "平塘县" }, - { "value": "1471", "label": "长顺县" }, - { "value": "1472", "label": "独山县" }, - { "value": "1473", "label": "三都水族自治县" } - ] - }], - "value": "1382", - "label": "贵州" - }, { - "children": [{ - "value": "1475", - "label": "海口", - "children": [ - { "value": "1476", "label": "海口市" }, - { "value": "4409", "label": "龙华区" }, - { "value": "4613", "label": "美兰区" }, - { "value": "4614", "label": "琼山区" }, - { "value": "4615", "label": "秀英区" }, - { "value": "460109", "label": "其它区" } - ] - }, { - "value": "1477", - "label": "三亚", - "children": [ - { "value": "1478", "label": "三亚市" } - ] - }, { - "value": "1479", - "label": "五指山", - "children": [ - { "value": "1480", "label": "五指山市" } - ] - }, { - "value": "1481", - "label": "琼海", - "children": [ - { "value": "1482", "label": "琼海市" } - ] - }, { - "value": "1483", - "label": "儋州", - "children": [ - { "value": "1484", "label": "儋州市" } - ] - }, { - "value": "1485", - "label": "文昌", - "children": [ - { "value": "1486", "label": "文昌市" } - ] - }, { - "value": "1487", - "label": "万宁", - "children": [ - { "value": "1488", "label": "万宁市" } - ] - }, { - "value": "1489", - "label": "东方", - "children": [ - { "value": "1490", "label": "东方市" } - ] - }, { - "value": "1491", - "label": "澄迈县", - "children": [ - { "value": "1492", "label": "澄迈县" } - ] - }, { - "value": "1493", - "label": "定安县", - "children": [ - { "value": "1494", "label": "定安县" } - ] - }, { - "value": "1495", - "label": "屯昌县", - "children": [ - { "value": "1496", "label": "屯昌县" } - ] - }, { - "value": "1497", - "label": "临高县", - "children": [ - { "value": "1498", "label": "临高县" } - ] - }, { - "value": "1499", - "label": "白沙黎族自治县", - "children": [ - { "value": "1500", "label": "白沙黎族自治县" } - ] - }, { - "value": "1501", - "label": "昌江黎族自治县", - "children": [ - { "value": "1502", "label": "昌江黎族自治县" } - ] - }, { - "value": "1503", - "label": "乐东黎族自治县", - "children": [ - { "value": "1504", "label": "乐东黎族自治县" } - ] - }, { - "value": "1505", - "label": "陵水黎族自治县", - "children": [ - { "value": "1506", "label": "陵水黎族自治县" } - ] - }, { - "value": "1507", - "label": "保亭黎族苗族自治县", - "children": [ - { "value": "1508", "label": "保亭黎族苗族自治县" } - ] - }, { - "value": "1509", - "label": "琼中黎族苗族自治县", - "children": [ - { "value": "1510", "label": "琼中黎族苗族自治县" } - ] - }, { - "value": "3705", - "label": "南沙群岛", - "children": [ - { "value": "3706", "label": "南沙群岛" } - ] - }, { - "value": "3707", - "label": "西沙群岛", - "children": [ - { "value": "3708", "label": "西沙群岛" } - ] - }, { - "value": "3709", - "label": "中沙群岛的岛礁及其海域", - "children": [ - { "value": "3780", "label": "中沙群岛的岛礁及其海域" } - ] - }], - "value": "1474", - "label": "海南" - }, { - "children": [{ - "value": "1512", - "label": "石家庄", - "children": [ - { "value": "1523", "label": "行唐县" }, - { "value": "1513", "label": "石家庄市" }, - { "value": "1514", "label": "辛集市" }, - { "value": "1515", "label": "藁城市" }, - { "value": "1516", "label": "晋州市" }, - { "value": "1517", "label": "新乐市" }, - { "value": "1518", "label": "鹿泉市" }, - { "value": "1519", "label": "平山县" }, - { "value": "1520", "label": "井陉县" }, - { "value": "1521", "label": "栾城县" }, - { "value": "1522", "label": "正定县" }, - { "value": "1524", "label": "灵寿县" }, - { "value": "1525", "label": "高邑县" }, - { "value": "1526", "label": "赵县" }, - { "value": "1527", "label": "赞皇县" }, - { "value": "1528", "label": "深泽县" }, - { "value": "1529", "label": "无极县" }, - { "value": "1530", "label": "元氏县" }, - { "value": "4412", "label": "桥西区" }, - { "value": "4632", "label": "长安区" }, - { "value": "4633", "label": "井陉矿区" }, - { "value": "4634", "label": "桥东区" }, - { "value": "4635", "label": "新华区" }, - { "value": "4636", "label": "裕华区" }, - { "value": "130186", "label": "其它区" } - ] - }, { - "value": "1531", - "label": "唐山", - "children": [ - { "value": "1535", "label": "迁西县" }, - { "value": "1532", "label": "唐山市" }, - { "value": "1533", "label": "遵化市" }, - { "value": "1534", "label": "迁安市" }, - { "value": "1536", "label": "滦南县" }, - { "value": "1537", "label": "玉田县" }, - { "value": "1538", "label": "唐海县" }, - { "value": "1539", "label": "乐亭县" }, - { "value": "1540", "label": "滦县" }, - { "value": "4637", "label": "丰南区" }, - { "value": "4638", "label": "丰润区" }, - { "value": "4639", "label": "古冶区" }, - { "value": "4640", "label": "开平区" }, - { "value": "4641", "label": "路北区" }, - { "value": "4642", "label": "路南区" }, - { "value": "130284", "label": "其它区" } - ] - }, { - "value": "1541", - "label": "秦皇岛", - "children": [ - { "value": "1542", "label": "秦皇岛市" }, - { "value": "1543", "label": "昌黎县" }, - { "value": "1544", "label": "卢龙县" }, - { "value": "1545", "label": "抚宁县" }, - { "value": "1546", "label": "青龙满族自治县" }, - { "value": "4629", "label": "北戴河区" }, - { "value": "4630", "label": "海港区" }, - { "value": "4631", "label": "山海关区" }, - { "value": "130398", "label": "其它区" } - ] - }, { - "value": "1547", - "label": "邯郸", - "children": [ - { "value": "1548", "label": "邯郸市" }, - { "value": "1549", "label": "武安市" }, - { "value": "1550", "label": "邯郸县" }, - { "value": "1551", "label": "永年县" }, - { "value": "1552", "label": "曲周县" }, - { "value": "1553", "label": "馆陶县" }, - { "value": "1554", "label": "魏县" }, - { "value": "1555", "label": "成安县" }, - { "value": "1556", "label": "大名县" }, - { "value": "1557", "label": "涉县" }, - { "value": "1558", "label": "鸡泽县" }, - { "value": "1559", "label": "邱县" }, - { "value": "1560", "label": "广平县" }, - { "value": "1561", "label": "肥乡县" }, - { "value": "1562", "label": "临漳县" }, - { "value": "1563", "label": "磁县" }, - { "value": "4623", "label": "丛台区" }, - { "value": "4624", "label": "峰峰矿区" }, - { "value": "4625", "label": "复兴区" }, - { "value": "4626", "label": "邯山区" }, - { "value": "130482", "label": "其它区" } - ] - }, { - "value": "1564", - "label": "邢台", - "children": [ - { "value": "1574", "label": "隆尧县" }, - { "value": "1565", "label": "邢台市" }, - { "value": "1566", "label": "南宫市" }, - { "value": "1567", "label": "沙河市" }, - { "value": "1568", "label": "邢台县" }, - { "value": "1569", "label": "柏乡县" }, - { "value": "1570", "label": "任县" }, - { "value": "1571", "label": "清河县" }, - { "value": "1572", "label": "宁晋县" }, - { "value": "1573", "label": "威县" }, - { "value": "1575", "label": "临城县" }, - { "value": "1576", "label": "广宗县" }, - { "value": "1577", "label": "临西县" }, - { "value": "1578", "label": "内丘县" }, - { "value": "1579", "label": "平乡县" }, - { "value": "1580", "label": "巨鹿县" }, - { "value": "1581", "label": "新河县" }, - { "value": "1582", "label": "南和县" }, - { "value": "4643", "label": "桥东区" }, - { "value": "4644", "label": "桥西区" }, - { "value": "130583", "label": "其它区" } - ] - }, { - "value": "1583", - "label": "保定", - "children": [ - { "value": "1587", "label": "安国市" }, - { "value": "1599", "label": "望都县" }, - { "value": "1584", "label": "保定市" }, - { "value": "1585", "label": "涿州市" }, - { "value": "1586", "label": "定州市" }, - { "value": "1588", "label": "高碑店市" }, - { "value": "1589", "label": "满城县" }, - { "value": "1590", "label": "清苑县" }, - { "value": "1591", "label": "涞水县" }, - { "value": "1592", "label": "阜平县" }, - { "value": "1593", "label": "徐水县" }, - { "value": "1594", "label": "定兴县" }, - { "value": "1595", "label": "唐县" }, - { "value": "1596", "label": "高阳县" }, - { "value": "1597", "label": "容城县" }, - { "value": "1598", "label": "涞源县" }, - { "value": "1600", "label": "安新县" }, - { "value": "1601", "label": "易县" }, - { "value": "1602", "label": "曲阳县" }, - { "value": "1603", "label": "蠡县" }, - { "value": "1604", "label": "顺平县" }, - { "value": "1605", "label": "博野县" }, - { "value": "1606", "label": "雄县" }, - { "value": "4616", "label": "北市区" }, - { "value": "4617", "label": "南市区" }, - { "value": "4618", "label": "新市区" }, - { "value": "130698", "label": "高开区" }, - { "value": "130699", "label": "其它区" } - ] - }, { - "value": "1607", - "label": "张家口", - "children": [ - { "value": "1608", "label": "张家口市" }, - { "value": "1609", "label": "宣化县" }, - { "value": "1610", "label": "康保县" }, - { "value": "1611", "label": "张北县" }, - { "value": "1612", "label": "阳原县" }, - { "value": "1613", "label": "赤城县" }, - { "value": "1614", "label": "沽源县" }, - { "value": "1615", "label": "怀安县" }, - { "value": "1616", "label": "怀来县" }, - { "value": "1617", "label": "崇礼县" }, - { "value": "1618", "label": "尚义县" }, - { "value": "1619", "label": "蔚县" }, - { "value": "1620", "label": "涿鹿县" }, - { "value": "1621", "label": "万全县" }, - { "value": "4645", "label": "桥东区" }, - { "value": "4646", "label": "桥西区" }, - { "value": "4647", "label": "下花园区" }, - { "value": "4648", "label": "宣化区" }, - { "value": "130734", "label": "其它区" } - ] - }, { - "value": "1622", - "label": "承德", - "children": [ - { "value": "1623", "label": "承德市" }, - { "value": "1624", "label": "承德县" }, - { "value": "1625", "label": "兴隆县" }, - { "value": "1626", "label": "隆化县" }, - { "value": "1627", "label": "平泉县" }, - { "value": "1628", "label": "滦平县" }, - { "value": "1629", "label": "丰宁满族自治县" }, - { "value": "1630", "label": "围场满族蒙古族自治县" }, - { "value": "1631", "label": "宽城满族自治县" }, - { "value": "4410", "label": "双滦区" }, - { "value": "4621", "label": "双桥区" }, - { "value": "4622", "label": "鹰手营子矿区" }, - { "value": "130829", "label": "其它区" } - ] - }, { - "value": "1632", - "label": "沧州", - "children": [ - { "value": "1646", "label": "吴桥县" }, - { "value": "1633", "label": "沧州市" }, - { "value": "1634", "label": "泊头市" }, - { "value": "1635", "label": "任丘市" }, - { "value": "1636", "label": "黄骅市" }, - { "value": "1637", "label": "河间市" }, - { "value": "1638", "label": "沧县" }, - { "value": "1639", "label": "青县" }, - { "value": "1640", "label": "献县" }, - { "value": "1641", "label": "东光县" }, - { "value": "1642", "label": "海兴县" }, - { "value": "1643", "label": "盐山县" }, - { "value": "1644", "label": "肃宁县" }, - { "value": "1645", "label": "南皮县" }, - { "value": "1647", "label": "孟村回族自治县" }, - { "value": "4619", "label": "新华区" }, - { "value": "4620", "label": "运河区" }, - { "value": "130985", "label": "其它区" } - ] - }, { - "value": "1648", - "label": "廊坊", - "children": [ - { "value": "1649", "label": "廊坊市" }, - { "value": "1650", "label": "霸州市" }, - { "value": "1651", "label": "三河市" }, - { "value": "1652", "label": "固安县" }, - { "value": "1653", "label": "永清县" }, - { "value": "1654", "label": "香河县" }, - { "value": "1655", "label": "大城县" }, - { "value": "1656", "label": "文安县" }, - { "value": "1657", "label": "大厂回族自治县" }, - { "value": "4411", "label": "安次区" }, - { "value": "4628", "label": "广阳区" }, - { "value": "131052", "label": "燕郊经济技术开发区" }, - { "value": "131083", "label": "其它区" } - ] - }, { - "value": "1658", - "label": "衡水", - "children": [ - { "value": "1659", "label": "衡水市" }, - { "value": "1660", "label": "冀州市" }, - { "value": "1661", "label": "深州市" }, - { "value": "1662", "label": "饶阳县" }, - { "value": "1663", "label": "枣强县" }, - { "value": "1664", "label": "故城县" }, - { "value": "1665", "label": "阜城县" }, - { "value": "1666", "label": "安平县" }, - { "value": "1667", "label": "武邑县" }, - { "value": "1668", "label": "景县" }, - { "value": "1669", "label": "武强县" }, - { "value": "4627", "label": "桃城区" }, - { "value": "131183", "label": "其它区" } - ] - }], - "value": "1511", - "label": "河北" - }, { - "children": [{ - "value": "1671", - "label": "郑州", - "children": [ - { "value": "1672", "label": "郑州市" }, - { "value": "1673", "label": "巩义市" }, - { "value": "1674", "label": "新郑市" }, - { "value": "1675", "label": "新密市" }, - { "value": "1676", "label": "登封市" }, - { "value": "1677", "label": "荥阳市" }, - { "value": "1678", "label": "中牟县" }, - { "value": "4689", "label": "二七区" }, - { "value": "4690", "label": "管城回族区" }, - { "value": "4691", "label": "惠济区" }, - { "value": "4692", "label": "金水区" }, - { "value": "4693", "label": "上街区" }, - { "value": "4694", "label": "中原区" }, - { "value": "10006", "label": "郑东新区" }, - { "value": "10007", "label": "郑州矿区" }, - { "value": "10008", "label": "高新技术产业开发区" }, - { "value": "10009", "label": "经济技术开发区" }, - { "value": "10010", "label": "出口加工区" }, - { "value": "410188", "label": "其它区" } - ] - }, { - "value": "1679", - "label": "开封", - "children": [ - { "value": "1680", "label": "开封市" }, - { "value": "1681", "label": "开封县" }, - { "value": "1682", "label": "尉氏县" }, - { "value": "1683", "label": "兰考县" }, - { "value": "1684", "label": "杞县" }, - { "value": "1685", "label": "通许县" }, - { "value": "4660", "label": "鼓楼区" }, - { "value": "4661", "label": "金明区" }, - { "value": "4662", "label": "龙亭区" }, - { "value": "4663", "label": "顺河回族区" }, - { "value": "4664", "label": "禹王台区" }, - { "value": "410226", "label": "其它区" } - ] - }, { - "value": "1686", - "label": "洛阳", - "children": [ - { "value": "1687", "label": "洛阳市" }, - { "value": "1688", "label": "偃师市" }, - { "value": "1689", "label": "孟津县" }, - { "value": "1690", "label": "汝阳县" }, - { "value": "1691", "label": "伊川县" }, - { "value": "1692", "label": "洛宁县" }, - { "value": "1693", "label": "嵩县" }, - { "value": "1694", "label": "宜阳县" }, - { "value": "1695", "label": "新安县" }, - { "value": "1696", "label": "栾川县" }, - { "value": "4665", "label": "廛河回族区" }, - { "value": "4666", "label": "吉利区" }, - { "value": "4667", "label": "涧西区" }, - { "value": "4668", "label": "老城区" }, - { "value": "4669", "label": "洛龙区" }, - { "value": "4670", "label": "西工区" }, - { "value": "10001", "label": "高新区" }, - { "value": "471005", "label": "其它区" } - ] - }, { - "value": "1697", - "label": "平顶山", - "children": [ - { "value": "1698", "label": "平顶山市" }, - { "value": "1699", "label": "汝州市" }, - { "value": "1700", "label": "舞钢市" }, - { "value": "1701", "label": "宝丰县" }, - { "value": "1702", "label": "叶县" }, - { "value": "1703", "label": "郏县" }, - { "value": "1704", "label": "鲁山县" }, - { "value": "4675", "label": "石龙区" }, - { "value": "4676", "label": "卫东区" }, - { "value": "4677", "label": "新华区" }, - { "value": "4678", "label": "湛河区" }, - { "value": "410483", "label": "其它区" } - ] - }, { - "value": "1705", - "label": "焦作", - "children": [ - { "value": "1706", "label": "焦作市" }, - { "value": "1707", "label": "沁阳市" }, - { "value": "1708", "label": "孟州市" }, - { "value": "1709", "label": "修武县" }, - { "value": "1710", "label": "温县" }, - { "value": "1711", "label": "武陟县" }, - { "value": "1712", "label": "博爱县" }, - { "value": "1815", "label": "济源市" }, - { "value": "4656", "label": "解放区" }, - { "value": "4657", "label": "马村区" }, - { "value": "4658", "label": "山阳区" }, - { "value": "4659", "label": "中站区" }, - { "value": "410884", "label": "其它区" } - ] - }, { - "value": "1713", - "label": "鹤壁", - "children": [ - { "value": "1714", "label": "鹤壁市" }, - { "value": "1715", "label": "浚县" }, - { "value": "1716", "label": "淇县" }, - { "value": "4653", "label": "鹤山区" }, - { "value": "4654", "label": "淇滨区" }, - { "value": "4655", "label": "山城区" }, - { "value": "410623", "label": "其它区" } - ] - }, { - "value": "1717", - "label": "新乡", - "children": [ - { "value": "1725", "label": "封丘县" }, - { "value": "1718", "label": "新乡市" }, - { "value": "1719", "label": "卫辉市" }, - { "value": "1720", "label": "辉县市" }, - { "value": "1721", "label": "新乡县" }, - { "value": "1722", "label": "获嘉县" }, - { "value": "1723", "label": "原阳县" }, - { "value": "1724", "label": "长垣县" }, - { "value": "1726", "label": "延津县" }, - { "value": "4683", "label": "凤泉区" }, - { "value": "4684", "label": "红旗区" }, - { "value": "4685", "label": "牧野区" }, - { "value": "4686", "label": "卫滨区" }, - { "value": "10000", "label": "高新区" }, - { "value": "410783", "label": "其它区" } - ] - }, { - "value": "1727", - "label": "安阳", - "children": [ - { "value": "1728", "label": "安阳市" }, - { "value": "1729", "label": "林州市" }, - { "value": "1730", "label": "安阳县" }, - { "value": "1731", "label": "滑县" }, - { "value": "1732", "label": "内黄县" }, - { "value": "1733", "label": "汤阴县" }, - { "value": "4649", "label": "北关区" }, - { "value": "4650", "label": "龙安区" }, - { "value": "4651", "label": "文峰区" }, - { "value": "4652", "label": "殷都区" }, - { "value": "410582", "label": "其它区" } - ] - }, { - "value": "1734", - "label": "濮阳", - "children": [ - { "value": "1737", "label": "南乐县" }, - { "value": "1735", "label": "濮阳市" }, - { "value": "1736", "label": "濮阳县" }, - { "value": "1738", "label": "台前县" }, - { "value": "1739", "label": "清丰县" }, - { "value": "1740", "label": "范县" }, - { "value": "4679", "label": "华龙区" }, - { "value": "410929", "label": "其它区" } - ] - }, { - "value": "1741", - "label": "许昌", - "children": [ - { "value": "1742", "label": "许昌市" }, - { "value": "1743", "label": "禹州市" }, - { "value": "1744", "label": "长葛市" }, - { "value": "1745", "label": "许昌县" }, - { "value": "1746", "label": "鄢陵县" }, - { "value": "1747", "label": "襄城县" }, - { "value": "4413", "label": "魏都区" }, - { "value": "411083", "label": "其它区" } - ] - }, { - "value": "1748", - "label": "漯河", - "children": [ - { "value": "1750", "label": "郾城区" }, - { "value": "1749", "label": "漯河市" }, - { "value": "1751", "label": "临颍县" }, - { "value": "1752", "label": "舞阳县" }, - { "value": "4671", "label": "源汇区" }, - { "value": "4672", "label": "召陵区" }, - { "value": "411123", "label": "其它区" } - ] - }, { - "value": "1753", - "label": "三门峡", - "children": [ - { "value": "1754", "label": "三门峡市" }, - { "value": "1755", "label": "义马市" }, - { "value": "1756", "label": "灵宝市" }, - { "value": "1757", "label": "渑池县" }, - { "value": "1758", "label": "卢氏县" }, - { "value": "1759", "label": "陕县" }, - { "value": "4680", "label": "湖滨区" }, - { "value": "411283", "label": "其它区" } - ] - }, { - "value": "1760", - "label": "南阳", - "children": [ - { "value": "1761", "label": "南阳市" }, - { "value": "1762", "label": "邓州市" }, - { "value": "1763", "label": "桐柏县" }, - { "value": "1764", "label": "方城县" }, - { "value": "1765", "label": "淅川县" }, - { "value": "1766", "label": "镇平县" }, - { "value": "1767", "label": "唐河县" }, - { "value": "1768", "label": "南召县" }, - { "value": "1769", "label": "内乡县" }, - { "value": "1770", "label": "新野县" }, - { "value": "1771", "label": "社旗县" }, - { "value": "1772", "label": "西峡县" }, - { "value": "4673", "label": "宛城区" }, - { "value": "4674", "label": "卧龙区" }, - { "value": "411382", "label": "其它区" } - ] - }, { - "value": "1773", - "label": "商丘", - "children": [ - { "value": "1776", "label": "宁陵县" }, - { "value": "1774", "label": "商丘市" }, - { "value": "1775", "label": "永城市" }, - { "value": "1777", "label": "虞城县" }, - { "value": "1778", "label": "民权县" }, - { "value": "1779", "label": "夏邑县" }, - { "value": "1780", "label": "柘城县" }, - { "value": "1781", "label": "睢县" }, - { "value": "4681", "label": "梁园区" }, - { "value": "4682", "label": "睢阳区" }, - { "value": "411482", "label": "其它区" } - ] - }, { - "value": "1782", - "label": "信阳", - "children": [ - { "value": "1788", "label": "商城县" }, - { "value": "1783", "label": "信阳市" }, - { "value": "1784", "label": "潢川县" }, - { "value": "1785", "label": "淮滨县" }, - { "value": "1786", "label": "息县" }, - { "value": "1787", "label": "新县" }, - { "value": "1789", "label": "固始县" }, - { "value": "1790", "label": "罗山县" }, - { "value": "1791", "label": "光山县" }, - { "value": "4687", "label": "平桥区" }, - { "value": "4688", "label": "浉河区" }, - { "value": "411529", "label": "其它区" } - ] - }, { - "value": "1792", - "label": "周口", - "children": [ - { "value": "1801", "label": "沈丘县" }, - { "value": "1793", "label": "周口市" }, - { "value": "1794", "label": "项城市" }, - { "value": "1795", "label": "商水县" }, - { "value": "1796", "label": "淮阳县" }, - { "value": "1797", "label": "太康县" }, - { "value": "1798", "label": "鹿邑县" }, - { "value": "1799", "label": "西华县" }, - { "value": "1800", "label": "扶沟县" }, - { "value": "1802", "label": "郸城县" }, - { "value": "4695", "label": "川汇区" }, - { "value": "411682", "label": "其它区" } - ] - }, { - "value": "1803", - "label": "驻马店", - "children": [ - { "value": "1804", "label": "驻马店市" }, - { "value": "1805", "label": "确山县" }, - { "value": "1806", "label": "新蔡县" }, - { "value": "1807", "label": "上蔡县" }, - { "value": "1808", "label": "西平县" }, - { "value": "1809", "label": "泌阳县" }, - { "value": "1810", "label": "平舆县" }, - { "value": "1811", "label": "汝南县" }, - { "value": "1812", "label": "遂平县" }, - { "value": "1813", "label": "正阳县" }, - { "value": "4414", "label": "驿城区" }, - { "value": "411730", "label": "其它区" } - ] - }], - "value": "1670", - "label": "河南" - }, { - "children": [{ - "value": "1817", - "label": "哈尔滨", - "children": [ - { "value": "1819", "label": "阿城区" }, - { "value": "1823", "label": "呼兰区" }, - { "value": "1818", "label": "哈尔滨市" }, - { "value": "1820", "label": "尚志市" }, - { "value": "1821", "label": "双城市" }, - { "value": "1822", "label": "五常市" }, - { "value": "1824", "label": "方正县" }, - { "value": "1825", "label": "宾县" }, - { "value": "1826", "label": "依兰县" }, - { "value": "1827", "label": "巴彦县" }, - { "value": "1828", "label": "通河县" }, - { "value": "1829", "label": "木兰县" }, - { "value": "1830", "label": "延寿县" }, - { "value": "4415", "label": "南岗区" }, - { "value": "4704", "label": "道里区" }, - { "value": "4705", "label": "道外区" }, - { "value": "4706", "label": "平房区" }, - { "value": "4707", "label": "松北区" }, - { "value": "4708", "label": "香坊区" }, - { "value": "230107", "label": "动力区" }, - { "value": "230181", "label": "阿城市" }, - { "value": "230185", "label": "阿城市" }, - { "value": "230186", "label": "其它区" } - ] - }, { - "value": "1831", - "label": "齐齐哈尔", - "children": [ - { "value": "1832", "label": "齐齐哈尔市" }, - { "value": "1833", "label": "讷河市" }, - { "value": "1834", "label": "富裕县" }, - { "value": "1835", "label": "拜泉县" }, - { "value": "1836", "label": "甘南县" }, - { "value": "1837", "label": "依安县" }, - { "value": "1838", "label": "克山县" }, - { "value": "1839", "label": "泰来县" }, - { "value": "1840", "label": "克东县" }, - { "value": "1841", "label": "龙江县" }, - { "value": "4418", "label": "富拉尔基区" }, - { "value": "4731", "label": "昂昂溪区" }, - { "value": "4732", "label": "建华区" }, - { "value": "4733", "label": "龙沙区" }, - { "value": "4734", "label": "梅里斯达斡尔族区" }, - { "value": "4735", "label": "碾子山区" }, - { "value": "4736", "label": "铁锋区" }, - { "value": "230282", "label": "其它区" } - ] - }, { - "value": "1842", - "label": "鹤岗", - "children": [ - { "value": "1843", "label": "鹤岗市" }, - { "value": "1844", "label": "萝北县" }, - { "value": "1845", "label": "绥滨县" }, - { "value": "4709", "label": "东山区" }, - { "value": "4710", "label": "工农区" }, - { "value": "4711", "label": "南山区" }, - { "value": "4712", "label": "向阳区" }, - { "value": "4713", "label": "兴安区" }, - { "value": "4714", "label": "兴山区" }, - { "value": "230423", "label": "其它区" } - ] - }, { - "value": "1846", - "label": "双鸭山", - "children": [ - { "value": "1849", "label": "宝清县" }, - { "value": "1847", "label": "双鸭山市" }, - { "value": "1848", "label": "集贤县" }, - { "value": "1850", "label": "友谊县" }, - { "value": "1851", "label": "饶河县" }, - { "value": "4737", "label": "宝山区" }, - { "value": "4738", "label": "尖山区" }, - { "value": "4739", "label": "岭东区" }, - { "value": "4740", "label": "四方台区" }, - { "value": "230525", "label": "其它区" } - ] - }, { - "value": "1852", - "label": "鸡西", - "children": [ - { "value": "1853", "label": "鸡西市" }, - { "value": "1854", "label": "密山市" }, - { "value": "1855", "label": "虎林市" }, - { "value": "1856", "label": "鸡东县" }, - { "value": "4715", "label": "城子河区" }, - { "value": "4716", "label": "滴道区" }, - { "value": "4717", "label": "恒山区" }, - { "value": "4718", "label": "鸡冠区" }, - { "value": "4719", "label": "梨树区" }, - { "value": "4720", "label": "麻山区" }, - { "value": "230383", "label": "其它区" } - ] - }, { - "value": "1857", - "label": "大庆", - "children": [ - { "value": "1861", "label": "肇源县" }, - { "value": "1858", "label": "大庆市" }, - { "value": "1859", "label": "林甸县" }, - { "value": "1860", "label": "肇州县" }, - { "value": "1862", "label": "杜尔伯特蒙古族自治县" }, - { "value": "4696", "label": "大同区" }, - { "value": "4697", "label": "红岗区" }, - { "value": "4698", "label": "龙凤区" }, - { "value": "4699", "label": "让胡路区" }, - { "value": "4700", "label": "萨尔图区" }, - { "value": "230625", "label": "其它区" } - ] - }, { - "value": "1863", - "label": "伊春", - "children": [ - { "value": "1864", "label": "伊春市" }, - { "value": "1865", "label": "铁力市" }, - { "value": "1866", "label": "嘉荫县" }, - { "value": "4419", "label": "红星区" }, - { "value": "4420", "label": "西林区" }, - { "value": "4742", "label": "翠峦区" }, - { "value": "4743", "label": "带岭区" }, - { "value": "4744", "label": "金山屯区" }, - { "value": "4745", "label": "美溪区" }, - { "value": "4746", "label": "南岔区" }, - { "value": "4747", "label": "上甘岭区" }, - { "value": "4748", "label": "汤旺河区" }, - { "value": "4749", "label": "乌马河区" }, - { "value": "4750", "label": "乌伊岭区" }, - { "value": "4751", "label": "五营区" }, - { "value": "4752", "label": "新青区" }, - { "value": "4753", "label": "伊春区" }, - { "value": "4754", "label": "友好区" }, - { "value": "230782", "label": "其它区" } - ] - }, { - "value": "1867", - "label": "牡丹江", - "children": [ - { "value": "1874", "label": "东宁县" }, - { "value": "1868", "label": "牡丹江市" }, - { "value": "1869", "label": "绥芬河市" }, - { "value": "1870", "label": "宁安市" }, - { "value": "1871", "label": "海林市" }, - { "value": "1872", "label": "穆棱市" }, - { "value": "1873", "label": "林口县" }, - { "value": "4724", "label": "爱民区" }, - { "value": "4725", "label": "东安区" }, - { "value": "4726", "label": "西安区" }, - { "value": "4727", "label": "阳明区" }, - { "value": "231086", "label": "其它区" } - ] - }, { - "value": "1875", - "label": "佳木斯", - "children": [ - { "value": "1876", "label": "佳木斯市" }, - { "value": "1877", "label": "同江市" }, - { "value": "1878", "label": "富锦市" }, - { "value": "1879", "label": "桦川县" }, - { "value": "1880", "label": "抚远县" }, - { "value": "1881", "label": "桦南县" }, - { "value": "1882", "label": "汤原县" }, - { "value": "4417", "label": "前进区" }, - { "value": "4721", "label": "东风区" }, - { "value": "4722", "label": "郊区" }, - { "value": "4723", "label": "向阳区" }, - { "value": "230802", "label": "永红区" }, - { "value": "230883", "label": "其它区" } - ] - }, { - "value": "1883", - "label": "七台河", - "children": [ - { "value": "1884", "label": "七台河市" }, - { "value": "1885", "label": "勃利县" }, - { "value": "4728", "label": "茄子河区" }, - { "value": "4729", "label": "桃山区" }, - { "value": "4730", "label": "新兴区" }, - { "value": "230922", "label": "其它区" } - ] - }, { - "value": "1886", - "label": "黑河", - "children": [ - { "value": "1887", "label": "黑河市" }, - { "value": "1888", "label": "北安市" }, - { "value": "1889", "label": "五大连池市" }, - { "value": "1890", "label": "逊克县" }, - { "value": "1891", "label": "嫩江县" }, - { "value": "1892", "label": "孙吴县" }, - { "value": "4416", "label": "爱辉区" }, - { "value": "231183", "label": "其它区" } - ] - }, { - "value": "1893", - "label": "绥化", - "children": [ - { "value": "1894", "label": "绥化市" }, - { "value": "1895", "label": "安达市" }, - { "value": "1896", "label": "肇东市" }, - { "value": "1897", "label": "海伦市" }, - { "value": "1898", "label": "绥棱县" }, - { "value": "1899", "label": "兰西县" }, - { "value": "1900", "label": "明水县" }, - { "value": "1901", "label": "青冈县" }, - { "value": "1902", "label": "庆安县" }, - { "value": "1903", "label": "望奎县" }, - { "value": "4741", "label": "北林区" }, - { "value": "231284", "label": "其它区" } - ] - }, { - "value": "1904", - "label": "大兴安岭", - "children": [ - { "value": "3704", "label": "大兴安岭市" }, - { "value": "1905", "label": "呼玛县" }, - { "value": "1906", "label": "塔河县" }, - { "value": "1907", "label": "漠河县" }, - { "value": "3703", "label": "加格达奇区" }, - { "value": "4701", "label": "呼中区" }, - { "value": "4702", "label": "松岭区" }, - { "value": "4703", "label": "新林区" } - ] - }], - "value": "1816", - "label": "黑龙江" - }, { - "children": [{ - "value": "2003", - "label": "长沙", - "children": [ - { "value": "2008", "label": "宁乡县" }, - { "value": "2004", "label": "长沙市" }, - { "value": "2005", "label": "浏阳市" }, - { "value": "2006", "label": "长沙县" }, - { "value": "2007", "label": "望城县" }, - { "value": "4425", "label": "芙蓉区" }, - { "value": "4790", "label": "开福区" }, - { "value": "4791", "label": "天心区" }, - { "value": "4792", "label": "雨花区" }, - { "value": "4793", "label": "岳麓区" }, - { "value": "430182", "label": "其它区" } - ] - }, { - "value": "2009", - "label": "株洲", - "children": [ - { "value": "2010", "label": "株洲市" }, - { "value": "2011", "label": "醴陵市" }, - { "value": "2012", "label": "株洲县" }, - { "value": "2013", "label": "炎陵县" }, - { "value": "2014", "label": "茶陵县" }, - { "value": "2015", "label": "攸县" }, - { "value": "4429", "label": "天元区" }, - { "value": "4816", "label": "荷塘区" }, - { "value": "4817", "label": "芦淞区" }, - { "value": "4818", "label": "石峰区" }, - { "value": "430282", "label": "其它区" } - ] - }, { - "value": "2016", - "label": "湘潭", - "children": [ - { "value": "2017", "label": "湘潭市" }, - { "value": "2018", "label": "湘乡市" }, - { "value": "2019", "label": "韶山市" }, - { "value": "2020", "label": "湘潭县" }, - { "value": "4806", "label": "雨湖区" }, - { "value": "4807", "label": "岳塘区" }, - { "value": "430383", "label": "其它区" } - ] - }, { - "value": "2021", - "label": "衡阳", - "children": [ - { "value": "2022", "label": "衡阳市" }, - { "value": "2023", "label": "耒阳市" }, - { "value": "2024", "label": "常宁市" }, - { "value": "2025", "label": "衡阳县" }, - { "value": "2026", "label": "衡东县" }, - { "value": "2027", "label": "衡山县" }, - { "value": "2028", "label": "衡南县" }, - { "value": "2029", "label": "祁东县" }, - { "value": "4797", "label": "南岳区" }, - { "value": "4798", "label": "石鼓区" }, - { "value": "4799", "label": "雁峰区" }, - { "value": "4800", "label": "蒸湘区" }, - { "value": "4801", "label": "珠晖区" }, - { "value": "430483", "label": "其它区" } - ] - }, { - "value": "2030", - "label": "邵阳", - "children": [ - { "value": "2031", "label": "邵阳市" }, - { "value": "2032", "label": "武冈市" }, - { "value": "2033", "label": "邵东县" }, - { "value": "2034", "label": "洞口县" }, - { "value": "2035", "label": "新邵县" }, - { "value": "2036", "label": "绥宁县" }, - { "value": "2037", "label": "新宁县" }, - { "value": "2038", "label": "邵阳县" }, - { "value": "2039", "label": "隆回县" }, - { "value": "2040", "label": "城步苗族自治县" }, - { "value": "4427", "label": "北塔区" }, - { "value": "4804", "label": "大祥区" }, - { "value": "4805", "label": "双清区" }, - { "value": "430582", "label": "其它区" } - ] - }, { - "value": "2041", - "label": "岳阳", - "children": [ - { "value": "2044", "label": "汨罗市" }, - { "value": "2042", "label": "岳阳市" }, - { "value": "2043", "label": "临湘市" }, - { "value": "2045", "label": "岳阳县" }, - { "value": "2046", "label": "湘阴县" }, - { "value": "2047", "label": "平江县" }, - { "value": "2048", "label": "华容县" }, - { "value": "4428", "label": "君山区" }, - { "value": "4812", "label": "岳阳楼区" }, - { "value": "4813", "label": "云溪区" }, - { "value": "430683", "label": "其它区" } - ] - }, { - "value": "2049", - "label": "常德", - "children": [ - { "value": "2056", "label": "安乡县" }, - { "value": "2050", "label": "常德市" }, - { "value": "2051", "label": "津市市" }, - { "value": "2052", "label": "澧县" }, - { "value": "2053", "label": "临澧县" }, - { "value": "2054", "label": "桃源县" }, - { "value": "2055", "label": "汉寿县" }, - { "value": "2057", "label": "石门县" }, - { "value": "4794", "label": "鼎城区" }, - { "value": "4795", "label": "武陵区" }, - { "value": "430782", "label": "其它区" } - ] - }, { - "value": "2058", - "label": "张家界", - "children": [ - { "value": "2059", "label": "张家界市" }, - { "value": "2060", "label": "慈利县" }, - { "value": "2061", "label": "桑植县" }, - { "value": "4814", "label": "武陵源区" }, - { "value": "4815", "label": "永定区" }, - { "value": "430823", "label": "其它区" } - ] - }, { - "value": "2062", - "label": "益阳", - "children": [ - { "value": "2063", "label": "益阳市" }, - { "value": "2064", "label": "沅江市" }, - { "value": "2065", "label": "桃江县" }, - { "value": "2066", "label": "南县" }, - { "value": "2067", "label": "安化县" }, - { "value": "4808", "label": "赫山区" }, - { "value": "4809", "label": "资阳区" }, - { "value": "430982", "label": "其它区" } - ] - }, { - "value": "2068", - "label": "郴州", - "children": [ - { "value": "2069", "label": "郴州市" }, - { "value": "2070", "label": "资兴市" }, - { "value": "2071", "label": "宜章县" }, - { "value": "2072", "label": "汝城县" }, - { "value": "2073", "label": "安仁县" }, - { "value": "2074", "label": "嘉禾县" }, - { "value": "2075", "label": "临武县" }, - { "value": "2076", "label": "桂东县" }, - { "value": "2077", "label": "永兴县" }, - { "value": "2078", "label": "桂阳县" }, - { "value": "4426", "label": "苏仙区" }, - { "value": "4796", "label": "北湖区" }, - { "value": "431082", "label": "其它区" } - ] - }, { - "value": "2079", - "label": "永州", - "children": [ - { "value": "2080", "label": "永州市" }, - { "value": "2081", "label": "祁阳县" }, - { "value": "2082", "label": "蓝山县" }, - { "value": "2083", "label": "宁远县" }, - { "value": "2084", "label": "新田县" }, - { "value": "2085", "label": "东安县" }, - { "value": "2086", "label": "江永县" }, - { "value": "2087", "label": "道县" }, - { "value": "2088", "label": "双牌县" }, - { "value": "2089", "label": "江华瑶族自治县" }, - { "value": "4810", "label": "冷水滩区" }, - { "value": "4811", "label": "零陵区" }, - { "value": "431130", "label": "其它区" } - ] - }, { - "value": "2090", - "label": "怀化", - "children": [ - { "value": "2091", "label": "怀化市" }, - { "value": "2092", "label": "洪江市" }, - { "value": "2093", "label": "会同县" }, - { "value": "2094", "label": "沅陵县" }, - { "value": "2095", "label": "辰溪县" }, - { "value": "2096", "label": "溆浦县" }, - { "value": "2097", "label": "中方县" }, - { "value": "2098", "label": "新晃侗族自治县" }, - { "value": "2099", "label": "芷江侗族自治县" }, - { "value": "2100", "label": "通道侗族自治县" }, - { "value": "2101", "label": "靖州苗族侗族自治县" }, - { "value": "2102", "label": "麻阳苗族自治县" }, - { "value": "4802", "label": "鹤城区" }, - { "value": "431282", "label": "其它区" } - ] - }, { - "value": "2103", - "label": "娄底", - "children": [ - { "value": "2104", "label": "娄底市" }, - { "value": "2105", "label": "冷水江市" }, - { "value": "2106", "label": "涟源市" }, - { "value": "2107", "label": "新化县" }, - { "value": "2108", "label": "双峰县" }, - { "value": "4803", "label": "娄星区" }, - { "value": "431383", "label": "其它区" } - ] - }, { - "value": "2109", - "label": "湘西土家族苗族自治州", - "children": [ - { "value": "2110", "label": "吉首市" }, - { "value": "2111", "label": "古丈县" }, - { "value": "2112", "label": "龙山县" }, - { "value": "2113", "label": "永顺县" }, - { "value": "2114", "label": "凤凰县" }, - { "value": "2115", "label": "泸溪县" }, - { "value": "2116", "label": "保靖县" }, - { "value": "2117", "label": "花垣县" } - ] - }], - "value": "2002", - "label": "湖南" - }, { - "children": [{ - "value": "2119", - "label": "长春", - "children": [ - { "value": "2123", "label": "德惠市" }, - { "value": "2120", "label": "长春市" }, - { "value": "2121", "label": "九台市" }, - { "value": "2122", "label": "榆树市" }, - { "value": "2124", "label": "农安县" }, - { "value": "4430", "label": "南关区" }, - { "value": "4821", "label": "朝阳区" }, - { "value": "4822", "label": "二道区" }, - { "value": "4823", "label": "宽城区" }, - { "value": "4824", "label": "绿园区" }, - { "value": "4825", "label": "双阳区" }, - { "value": "220185", "label": "汽车产业开发区" }, - { "value": "220187", "label": "净月旅游开发区" }, - { "value": "220188", "label": "其它区" } - ] - }, { - "value": "2125", - "label": "吉林", - "children": [ - { "value": "2126", "label": "吉林市" }, - { "value": "2127", "label": "舒兰市" }, - { "value": "2128", "label": "桦甸市" }, - { "value": "2129", "label": "蛟河市" }, - { "value": "2130", "label": "磐石市" }, - { "value": "2131", "label": "永吉县" }, - { "value": "4826", "label": "昌邑区" }, - { "value": "4827", "label": "船营区" }, - { "value": "4828", "label": "丰满区" }, - { "value": "4829", "label": "龙潭区" }, - { "value": "220285", "label": "其它区" } - ] - }, { - "value": "2132", - "label": "四平", - "children": [ - { "value": "2135", "label": "双辽市" }, - { "value": "2133", "label": "四平市" }, - { "value": "2134", "label": "公主岭市" }, - { "value": "2136", "label": "梨树县" }, - { "value": "2137", "label": "伊通满族自治县" }, - { "value": "4431", "label": "铁西区" }, - { "value": "4832", "label": "铁东区" }, - { "value": "220383", "label": "其它区" } - ] - }, { - "value": "2138", - "label": "辽源", - "children": [ - { "value": "2139", "label": "辽源市" }, - { "value": "2140", "label": "东辽县" }, - { "value": "2141", "label": "东丰县" }, - { "value": "4830", "label": "龙山区" }, - { "value": "4831", "label": "西安区" }, - { "value": "220423", "label": "其它区" } - ] - }, { - "value": "2142", - "label": "通化", - "children": [ - { "value": "2143", "label": "通化市" }, - { "value": "2144", "label": "梅河口市" }, - { "value": "2145", "label": "集安市" }, - { "value": "2146", "label": "通化县" }, - { "value": "2147", "label": "辉南县" }, - { "value": "2148", "label": "柳河县" }, - { "value": "4834", "label": "东昌区" }, - { "value": "4835", "label": "二道江区" }, - { "value": "220583", "label": "其它区" } - ] - }, { - "value": "2149", - "label": "白山", - "children": [ - { "value": "2154", "label": "江源区" }, - { "value": "2150", "label": "白山市" }, - { "value": "2151", "label": "临江市" }, - { "value": "2152", "label": "靖宇县" }, - { "value": "2153", "label": "抚松县" }, - { "value": "2155", "label": "长白朝鲜族自治县" }, - { "value": "4820", "label": "八道江区" }, - { "value": "220625", "label": "江源县" }, - { "value": "220682", "label": "其它区" } - ] - }, { - "value": "2156", - "label": "松原", - "children": [ - { "value": "2157", "label": "松原市" }, - { "value": "2158", "label": "乾安县" }, - { "value": "2159", "label": "长岭县" }, - { "value": "2160", "label": "扶余县" }, - { "value": "2161", "label": "前郭尔罗斯蒙古族自治县" }, - { "value": "4833", "label": "宁江区" }, - { "value": "220725", "label": "其它区" } - ] - }, { - "value": "2162", - "label": "白城", - "children": [ - { "value": "2163", "label": "白城市" }, - { "value": "2164", "label": "大安市" }, - { "value": "2165", "label": "洮南市" }, - { "value": "2166", "label": "镇赉县" }, - { "value": "2167", "label": "通榆县" }, - { "value": "4819", "label": "洮北区" }, - { "value": "220883", "label": "其它区" } - ] - }, { - "value": "2168", - "label": "延边朝鲜族自治州", - "children": [ - { "value": "2169", "label": "延吉市" }, - { "value": "2170", "label": "图们市" }, - { "value": "2171", "label": "敦化市" }, - { "value": "2172", "label": "龙井市" }, - { "value": "2173", "label": "珲春市" }, - { "value": "2174", "label": "和龙市" }, - { "value": "2175", "label": "安图县" }, - { "value": "2176", "label": "汪清县" } - ] - }], - "value": "2118", - "label": "吉林" - }, { - "children": [{ - "value": "2178", - "label": "南京", - "children": [ - { "value": "2179", "label": "南京市" }, - { "value": "2180", "label": "溧水县" }, - { "value": "2181", "label": "高淳县" }, - { "value": "4002", "label": "白下区" }, - { "value": "4003", "label": "鼓楼区" }, - { "value": "4004", "label": "建邺区" }, - { "value": "4005", "label": "江宁区" }, - { "value": "4006", "label": "六合区" }, - { "value": "4007", "label": "浦口区" }, - { "value": "4008", "label": "栖霞区" }, - { "value": "4009", "label": "秦淮区" }, - { "value": "4010", "label": "下关区" }, - { "value": "4011", "label": "玄武区" }, - { "value": "4012", "label": "雨花台区" }, - { "value": "320126", "label": "其它区" } - ] - }, { - "value": "2182", - "label": "徐州", - "children": [ - { "value": "2185", "label": "新沂市" }, - { "value": "2183", "label": "徐州市" }, - { "value": "2184", "label": "邳州市" }, - { "value": "2186", "label": "铜山县" }, - { "value": "2187", "label": "睢宁县" }, - { "value": "2188", "label": "沛县" }, - { "value": "2189", "label": "丰县" }, - { "value": "4030", "label": "鼓楼区" }, - { "value": "4031", "label": "贾汪区" }, - { "value": "4032", "label": "泉山区" }, - { "value": "4033", "label": "云龙区" }, - { "value": "4366", "label": "九里区" }, - { "value": "320383", "label": "其它区" } - ] - }, { - "value": "2190", - "label": "连云港", - "children": [ - { "value": "2191", "label": "连云港市" }, - { "value": "2192", "label": "东海县" }, - { "value": "2193", "label": "灌云县" }, - { "value": "2194", "label": "赣榆县" }, - { "value": "2195", "label": "灌南县" }, - { "value": "4000", "label": "连云区" }, - { "value": "4001", "label": "新浦区" }, - { "value": "4844", "label": "海州区" }, - { "value": "320725", "label": "其它区" } - ] - }, { - "value": "2196", - "label": "淮安", - "children": [ - { "value": "2198", "label": "涟水县" }, - { "value": "2197", "label": "淮安市" }, - { "value": "2199", "label": "洪泽县" }, - { "value": "2200", "label": "金湖县" }, - { "value": "2201", "label": "盱眙县" }, - { "value": "4840", "label": "楚州区" }, - { "value": "4841", "label": "淮阴区" }, - { "value": "4842", "label": "清河区" }, - { "value": "4843", "label": "清浦区" }, - { "value": "320832", "label": "其它区" } - ] - }, { - "value": "2202", - "label": "宿迁", - "children": [ - { "value": "2204", "label": "宿豫区" }, - { "value": "2203", "label": "宿迁市" }, - { "value": "2205", "label": "沭阳县" }, - { "value": "2206", "label": "泗阳县" }, - { "value": "2207", "label": "泗洪县" }, - { "value": "4021", "label": "宿城区" }, - { "value": "321325", "label": "其它区" } - ] - }, { - "value": "2208", - "label": "盐城", - "children": [ - { "value": "2212", "label": "盐都区" }, - { "value": "2210", "label": "东台市" }, - { "value": "2209", "label": "盐城市" }, - { "value": "2211", "label": "大丰市" }, - { "value": "2213", "label": "建湖县" }, - { "value": "2214", "label": "响水县" }, - { "value": "2215", "label": "阜宁县" }, - { "value": "2216", "label": "射阳县" }, - { "value": "2217", "label": "滨海县" }, - { "value": "4034", "label": "亭湖区" }, - { "value": "320983", "label": "其它区" } - ] - }, { - "value": "2218", - "label": "扬州", - "children": [ - { "value": "2219", "label": "扬州市" }, - { "value": "2220", "label": "高邮市" }, - { "value": "2221", "label": "江都市" }, - { "value": "2222", "label": "仪征市" }, - { "value": "2223", "label": "宝应县" }, - { "value": "4035", "label": "广陵区" }, - { "value": "4036", "label": "邗江区" }, - { "value": "4037", "label": "维扬区" }, - { "value": "321093", "label": "其它区" } - ] - }, { - "value": "2224", - "label": "泰州", - "children": [ - { "value": "2225", "label": "泰州市" }, - { "value": "2226", "label": "泰兴市" }, - { "value": "2227", "label": "姜堰市" }, - { "value": "2228", "label": "靖江市" }, - { "value": "2229", "label": "兴化市" }, - { "value": "4022", "label": "高港区" }, - { "value": "4023", "label": "海陵区" }, - { "value": "321285", "label": "其它区" } - ] - }, { - "value": "2230", - "label": "南通", - "children": [ - { "value": "2231", "label": "南通市" }, - { "value": "2232", "label": "如皋市" }, - { "value": "2233", "label": "通州市" }, - { "value": "2234", "label": "海门市" }, - { "value": "2235", "label": "启东市" }, - { "value": "2236", "label": "海安县" }, - { "value": "2237", "label": "如东县" }, - { "value": "4013", "label": "崇川区" }, - { "value": "4014", "label": "港闸区" }, - { "value": "320694", "label": "其它区" } - ] - }, { - "value": "2238", - "label": "镇江", - "children": [ - { "value": "2239", "label": "镇江市" }, - { "value": "2240", "label": "丹阳市" }, - { "value": "2241", "label": "扬中市" }, - { "value": "2242", "label": "句容市" }, - { "value": "4038", "label": "丹徒区" }, - { "value": "4039", "label": "润州区" }, - { "value": "4367", "label": "京口区" }, - { "value": "321184", "label": "其它区" } - ] - }, { - "value": "2243", - "label": "常州", - "children": [ - { "value": "2246", "label": "溧阳市" }, - { "value": "2244", "label": "常州市" }, - { "value": "2245", "label": "金坛市" }, - { "value": "4432", "label": "钟楼区" }, - { "value": "4836", "label": "戚墅堰区" }, - { "value": "4837", "label": "天宁区" }, - { "value": "4838", "label": "武进区" }, - { "value": "4839", "label": "新北区" }, - { "value": "320483", "label": "其它区" } - ] - }, { - "value": "2247", - "label": "无锡", - "children": [ - { "value": "2248", "label": "无锡市" }, - { "value": "2249", "label": "江阴市" }, - { "value": "2250", "label": "宜兴市" }, - { "value": "4024", "label": "北塘区" }, - { "value": "4025", "label": "滨湖区" }, - { "value": "4026", "label": "崇安区" }, - { "value": "4027", "label": "惠山区" }, - { "value": "4028", "label": "南长区" }, - { "value": "4029", "label": "锡山区" }, - { "value": "320296", "label": "新区" }, - { "value": "320297", "label": "其它区" } - ] - }, { - "value": "2251", - "label": "苏州", - "children": [ - { "value": "2252", "label": "苏州市" }, - { "value": "2253", "label": "常熟市" }, - { "value": "2254", "label": "张家港市" }, - { "value": "2255", "label": "太仓市" }, - { "value": "2256", "label": "昆山市" }, - { "value": "2257", "label": "吴江市" }, - { "value": "4015", "label": "沧浪区" }, - { "value": "4016", "label": "虎丘区" }, - { "value": "4017", "label": "金阊区" }, - { "value": "4018", "label": "平江区" }, - { "value": "4019", "label": "吴中区" }, - { "value": "4020", "label": "相城区" }, - { "value": "320594", "label": "新区" }, - { "value": "320595", "label": "园区" }, - { "value": "320596", "label": "其它区" } - ] - }], - "value": "2177", - "label": "江苏" - }, { - "children": [{ - "value": "2362", - "label": "沈阳", - "children": [ - { "value": "2363", "label": "沈阳市" }, - { "value": "2364", "label": "新民市" }, - { "value": "2365", "label": "法库县" }, - { "value": "2366", "label": "辽中县" }, - { "value": "2367", "label": "康平县" }, - { "value": "4093", "label": "大东区" }, - { "value": "4094", "label": "东陵区" }, - { "value": "4095", "label": "和平区" }, - { "value": "4096", "label": "皇姑区" }, - { "value": "4097", "label": "沈北新区" }, - { "value": "4098", "label": "沈河区" }, - { "value": "4099", "label": "苏家屯区" }, - { "value": "4100", "label": "于洪区" }, - { "value": "4375", "label": "铁西区" }, - { "value": "210113", "label": "新城子区" }, - { "value": "210182", "label": "浑南新区" }, - { "value": "210183", "label": "张士开发区" }, - { "value": "210185", "label": "其它区" } - ] - }, { - "value": "2368", - "label": "大连", - "children": [ - { "value": "2369", "label": "大连市" }, - { "value": "2370", "label": "瓦房店市" }, - { "value": "2371", "label": "普兰店市" }, - { "value": "2372", "label": "庄河市" }, - { "value": "2373", "label": "长海县" }, - { "value": "4066", "label": "甘井子区" }, - { "value": "4067", "label": "金州区" }, - { "value": "4068", "label": "沙河口区" }, - { "value": "4069", "label": "西岗区" }, - { "value": "4070", "label": "中山区" }, - { "value": "4371", "label": "旅顺口区" }, - { "value": "210297", "label": "岭前区" }, - { "value": "210298", "label": "其它区" } - ] - }, { - "value": "2374", - "label": "鞍山", - "children": [ - { "value": "2375", "label": "鞍山市" }, - { "value": "2376", "label": "海城市" }, - { "value": "2377", "label": "台安县" }, - { "value": "2378", "label": "岫岩满族自治县" }, - { "value": "4057", "label": "立山区" }, - { "value": "4058", "label": "千山区" }, - { "value": "4059", "label": "铁东区" }, - { "value": "4060", "label": "铁西区" }, - { "value": "210382", "label": "其它区" } - ] - }, { - "value": "2379", - "label": "抚顺", - "children": [ - { "value": "2380", "label": "抚顺市" }, - { "value": "2381", "label": "抚顺县" }, - { "value": "2382", "label": "清原满族自治县" }, - { "value": "2383", "label": "新宾满族自治县" }, - { "value": "4074", "label": "东洲区" }, - { "value": "4075", "label": "顺城区" }, - { "value": "4076", "label": "新抚区" }, - { "value": "4372", "label": "望花区" }, - { "value": "210424", "label": "其它区" } - ] - }, { - "value": "2384", - "label": "本溪", - "children": [ - { "value": "2385", "label": "本溪市" }, - { "value": "2386", "label": "本溪满族自治县" }, - { "value": "2387", "label": "桓仁满族自治县" }, - { "value": "4061", "label": "南芬区" }, - { "value": "4062", "label": "平山区" }, - { "value": "4063", "label": "溪湖区" }, - { "value": "4370", "label": "明山区" }, - { "value": "210523", "label": "其它区" } - ] - }, { - "value": "2388", - "label": "丹东", - "children": [ - { "value": "2389", "label": "丹东市" }, - { "value": "2390", "label": "东港市" }, - { "value": "2391", "label": "凤城市" }, - { "value": "2392", "label": "宽甸满族自治县" }, - { "value": "4071", "label": "元宝区" }, - { "value": "4072", "label": "振安区" }, - { "value": "4073", "label": "振兴区" }, - { "value": "210683", "label": "其它区" } - ] - }, { - "value": "2393", - "label": "锦州", - "children": [ - { "value": "2396", "label": "北镇市" }, - { "value": "2394", "label": "锦州市" }, - { "value": "2395", "label": "凌海市" }, - { "value": "2397", "label": "黑山县" }, - { "value": "2398", "label": "义县" }, - { "value": "4084", "label": "古塔区" }, - { "value": "4085", "label": "凌河区" }, - { "value": "4086", "label": "太和区" }, - { "value": "210783", "label": "其它区" } - ] - }, { - "value": "2399", - "label": "葫芦岛", - "children": [ - { "value": "2400", "label": "葫芦岛市" }, - { "value": "2401", "label": "兴城市" }, - { "value": "2402", "label": "绥中县" }, - { "value": "2403", "label": "建昌县" }, - { "value": "4082", "label": "连山区" }, - { "value": "4083", "label": "南票区" }, - { "value": "4373", "label": "龙港区" }, - { "value": "211482", "label": "其它区" } - ] - }, { - "value": "2404", - "label": "营口", - "children": [ - { "value": "2405", "label": "营口市" }, - { "value": "2406", "label": "大石桥市" }, - { "value": "2407", "label": "盖州市" }, - { "value": "4103", "label": "鲅鱼圈区" }, - { "value": "4104", "label": "老边区" }, - { "value": "4105", "label": "西市区" }, - { "value": "4106", "label": "站前区" }, - { "value": "210883", "label": "其它区" } - ] - }, { - "value": "2408", - "label": "盘锦", - "children": [ - { "value": "2409", "label": "盘锦市" }, - { "value": "2410", "label": "盘山县" }, - { "value": "2411", "label": "大洼县" }, - { "value": "4092", "label": "兴隆台区" }, - { "value": "4374", "label": "双台子区" }, - { "value": "211123", "label": "其它区" } - ] - }, { - "value": "2412", - "label": "阜新", - "children": [ - { "value": "2413", "label": "阜新市" }, - { "value": "2414", "label": "彰武县" }, - { "value": "2415", "label": "阜新蒙古族自治县" }, - { "value": "4077", "label": "海州区" }, - { "value": "4078", "label": "清河门区" }, - { "value": "4079", "label": "太平区" }, - { "value": "4080", "label": "细河区" }, - { "value": "4081", "label": "新邱区" }, - { "value": "210923", "label": "其它区" } - ] - }, { - "value": "2416", - "label": "辽阳", - "children": [ - { "value": "2417", "label": "辽阳市" }, - { "value": "2418", "label": "灯塔市" }, - { "value": "2419", "label": "辽阳县" }, - { "value": "4087", "label": "白塔区" }, - { "value": "4088", "label": "弓长岭区" }, - { "value": "4089", "label": "宏伟区" }, - { "value": "4090", "label": "太子河区" }, - { "value": "4091", "label": "文圣区" }, - { "value": "211082", "label": "其它区" } - ] - }, { - "value": "2420", - "label": "铁岭", - "children": [ - { "value": "2421", "label": "铁岭市" }, - { "value": "2422", "label": "调兵山市" }, - { "value": "2423", "label": "开原市" }, - { "value": "2424", "label": "铁岭县" }, - { "value": "2425", "label": "昌图县" }, - { "value": "2426", "label": "西丰县" }, - { "value": "4101", "label": "清河区" }, - { "value": "4102", "label": "银州区" }, - { "value": "211283", "label": "其它区" } - ] - }, { - "value": "2427", - "label": "朝阳", - "children": [ - { "value": "2428", "label": "朝阳市" }, - { "value": "2429", "label": "凌源市" }, - { "value": "2430", "label": "北票市" }, - { "value": "2431", "label": "朝阳县" }, - { "value": "2432", "label": "建平县" }, - { "value": "2433", "label": "喀喇沁左翼蒙古族自治县" }, - { "value": "4064", "label": "龙城区" }, - { "value": "4065", "label": "双塔区" }, - { "value": "211383", "label": "其它区" } - ] - }], - "value": "2361", - "label": "辽宁" - }, { - "children": [{ - "value": "2435", - "label": "呼和浩特", - "children": [ - { "value": "2436", "label": "呼和浩特市" }, - { "value": "2437", "label": "托克托县" }, - { "value": "2438", "label": "清水河县" }, - { "value": "2439", "label": "武川县" }, - { "value": "2440", "label": "和林格尔县" }, - { "value": "2441", "label": "土默特左旗" }, - { "value": "4116", "label": "赛罕区" }, - { "value": "4117", "label": "新城区" }, - { "value": "4118", "label": "玉泉区" }, - { "value": "4377", "label": "回民区" }, - { "value": "150126", "label": "其它区" } - ] - }, { - "value": "2442", - "label": "包头", - "children": [ - { "value": "2443", "label": "包头市" }, - { "value": "2444", "label": "固阳县" }, - { "value": "2445", "label": "土默特右旗" }, - { "value": "2446", "label": "达尔罕茂明安联合旗" }, - { "value": "4107", "label": "东河区" }, - { "value": "4108", "label": "九原区" }, - { "value": "4109", "label": "昆都仑区" }, - { "value": "4110", "label": "青山区" }, - { "value": "4111", "label": "石拐区" }, - { "value": "4376", "label": "白云矿区" }, - { "value": "150224", "label": "其它区" } - ] - }, { - "value": "2447", - "label": "乌海", - "children": [ - { "value": "2448", "label": "乌海市" }, - { "value": "4121", "label": "海勃湾区" }, - { "value": "4122", "label": "海南区" }, - { "value": "4123", "label": "乌达区" }, - { "value": "150305", "label": "其它区" } - ] - }, { - "value": "2449", - "label": "赤峰", - "children": [ - { "value": "2459", "label": "巴林右旗" }, - { "value": "2450", "label": "赤峰市" }, - { "value": "2451", "label": "宁城县" }, - { "value": "2452", "label": "林西县" }, - { "value": "2453", "label": "喀喇沁旗" }, - { "value": "2454", "label": "巴林左旗" }, - { "value": "2455", "label": "敖汉旗" }, - { "value": "2456", "label": "阿鲁科尔沁旗" }, - { "value": "2457", "label": "翁牛特旗" }, - { "value": "2458", "label": "克什克腾旗" }, - { "value": "4112", "label": "红山区" }, - { "value": "4113", "label": "松山区" }, - { "value": "4114", "label": "元宝山区" }, - { "value": "150431", "label": "其它区" } - ] - }, { - "value": "2460", - "label": "通辽", - "children": [ - { "value": "2461", "label": "通辽市" }, - { "value": "2462", "label": "霍林郭勒市" }, - { "value": "2463", "label": "开鲁县" }, - { "value": "2464", "label": "科尔沁左翼中旗" }, - { "value": "2465", "label": "科尔沁左翼后旗" }, - { "value": "2466", "label": "库伦旗" }, - { "value": "2467", "label": "奈曼旗" }, - { "value": "2468", "label": "扎鲁特旗" }, - { "value": "4120", "label": "科尔沁区" }, - { "value": "150582", "label": "其它区" } - ] - }, { - "value": "2469", - "label": "鄂尔多斯", - "children": [ - { "value": "2472", "label": "乌审旗" }, - { "value": "2470", "label": "鄂尔多斯市" }, - { "value": "2471", "label": "准格尔旗" }, - { "value": "2473", "label": "伊金霍洛旗" }, - { "value": "2474", "label": "鄂托克旗" }, - { "value": "2475", "label": "鄂托克前旗" }, - { "value": "2476", "label": "杭锦旗" }, - { "value": "2477", "label": "达拉特旗" }, - { "value": "4115", "label": "东胜区" }, - { "value": "150628", "label": "其它区" } - ] - }, { - "value": "2478", - "label": "呼伦贝尔", - "children": [ - { "value": "2491", "label": "鄂温克族自治旗" }, - { "value": "2479", "label": "呼伦贝尔市" }, - { "value": "2480", "label": "满洲里市" }, - { "value": "2481", "label": "牙克石市" }, - { "value": "2482", "label": "扎兰屯市" }, - { "value": "2483", "label": "根河市" }, - { "value": "2484", "label": "额尔古纳市" }, - { "value": "2485", "label": "陈巴尔虎旗" }, - { "value": "2486", "label": "阿荣旗" }, - { "value": "2487", "label": "新巴尔虎左旗" }, - { "value": "2488", "label": "新巴尔虎右旗" }, - { "value": "2489", "label": "鄂伦春自治旗" }, - { "value": "2490", "label": "莫力达瓦达斡尔族自治旗" }, - { "value": "4119", "label": "海拉尔区" }, - { "value": "150786", "label": "其它区" } - ] - }, { - "value": "2492", - "label": "乌兰察布", - "children": [ - { "value": "2493", "label": "乌兰察布市" }, - { "value": "2494", "label": "丰镇市" }, - { "value": "2495", "label": "兴和县" }, - { "value": "2496", "label": "卓资县" }, - { "value": "2497", "label": "商都县" }, - { "value": "2498", "label": "凉城县" }, - { "value": "2499", "label": "化德县" }, - { "value": "2500", "label": "察哈尔右翼前旗" }, - { "value": "2501", "label": "察哈尔右翼中旗" }, - { "value": "2502", "label": "察哈尔右翼后旗" }, - { "value": "2503", "label": "四子王旗" }, - { "value": "4124", "label": "集宁区" }, - { "value": "150982", "label": "其它区" } - ] - }, { - "value": "2504", - "label": "锡林郭勒盟", - "children": [ - { "value": "2505", "label": "锡林浩特市" }, - { "value": "2506", "label": "二连浩特市" }, - { "value": "2507", "label": "多伦县" }, - { "value": "2508", "label": "阿巴嘎旗" }, - { "value": "2509", "label": "西乌珠穆沁旗" }, - { "value": "2510", "label": "东乌珠穆沁旗" }, - { "value": "2511", "label": "苏尼特左旗" }, - { "value": "2512", "label": "苏尼特右旗" }, - { "value": "2513", "label": "太仆寺旗" }, - { "value": "2514", "label": "正镶白旗" }, - { "value": "2515", "label": "正蓝旗" }, - { "value": "2516", "label": "镶黄旗" } - ] - }, { - "value": "2517", - "label": "巴彦淖尔市", - "children": [ - { "value": "2518", "label": "临河区" }, - { "value": "2524", "label": "乌拉特后旗" }, - { "value": "3785", "label": "巴彦淖尔市" }, - { "value": "2519", "label": "五原县" }, - { "value": "2520", "label": "磴口县" }, - { "value": "2521", "label": "杭锦后旗" }, - { "value": "2522", "label": "乌拉特中旗" }, - { "value": "2523", "label": "乌拉特前旗" }, - { "value": "150827", "label": "其它区" } - ] - }, { - "value": "2525", - "label": "阿拉善盟", - "children": [ - { "value": "2526", "label": "阿拉善左旗" }, - { "value": "2527", "label": "阿拉善右旗" }, - { "value": "2528", "label": "额济纳旗" } - ] - }, { - "value": "2529", - "label": "兴安盟", - "children": [ - { "value": "2530", "label": "乌兰浩特市" }, - { "value": "2531", "label": "阿尔山市" }, - { "value": "2532", "label": "突泉县" }, - { "value": "2533", "label": "扎赉特旗" }, - { "value": "2534", "label": "科尔沁右翼前旗" }, - { "value": "2535", "label": "科尔沁右翼中旗" } - ] - }], - "value": "2434", - "label": "内蒙古" - }, { - "children": [{ - "value": "4847", - "label": "香港岛", - "children": [ - { "value": "4848", "label": "香港岛" }, - { "value": "810101", "label": "中西区" }, - { "value": "810102", "label": "湾仔" }, - { "value": "810104", "label": "南区" } - ] - }, { - "value": "4849", - "label": "九龙", - "children": [ - { "value": "4850", "label": "九龙" }, - { "value": "810201", "label": "九龙城区" }, - { "value": "810202", "label": "油尖旺区" }, - { "value": "810203", "label": "深水埗区" }, - { "value": "810204", "label": "黄大仙区" }, - { "value": "810205", "label": "观塘区" } - ] - }, { - "value": "4851", - "label": "新界", - "children": [ - { "value": "4852", "label": "新界" }, - { "value": "810301", "label": "北区" }, - { "value": "810302", "label": "大埔区" }, - { "value": "810303", "label": "沙田区" }, - { "value": "810304", "label": "西贡区" }, - { "value": "810305", "label": "元朗区" }, - { "value": "810306", "label": "屯门区" }, - { "value": "810307", "label": "荃湾区" }, - { "value": "810308", "label": "葵青区" }, - { "value": "810309", "label": "离岛区" } - ] - }], - "value": "4846", - "label": "香港" - }, { - "children": [{ - "value": "4854", - "label": "澳门半岛", - "children": [ - { "value": "4855", "label": "澳门半岛" } - ] - }, { - "value": "4856", - "label": "澳门离岛", - "children": [ - { "value": "4857", "label": "澳门离岛" } - ] - }], - "value": "4853", - "label": "澳门" - }, { - "children": [{ - "value": "4859", - "label": "台北县", - "children": [ - { "value": "4860", "label": "台北县" } - ] - }, { - "value": "4861", - "label": "宜兰县", - "children": [ - { "value": "4862", "label": "宜兰县" } - ] - }, { - "value": "4863", - "label": "桃园县", - "children": [ - { "value": "4864", "label": "桃园县" } - ] - }, { - "value": "4865", - "label": "新竹县", - "children": [ - { "value": "4866", "label": "新竹县" } - ] - }, { - "value": "4867", - "label": "苗栗县", - "children": [ - { "value": "4868", "label": "苗栗县" } - ] - }, { - "value": "4869", - "label": "台中县", - "children": [ - { "value": "4870", "label": "台中县" } - ] - }, { - "value": "4871", - "label": "彰化县", - "children": [ - { "value": "4872", "label": "彰化县" } - ] - }, { - "value": "4873", - "label": "南投县", - "children": [ - { "value": "4874", "label": "南投县" } - ] - }, { - "value": "4875", - "label": "云林县", - "children": [ - { "value": "4876", "label": "云林县" } - ] - }, { - "value": "4877", - "label": "嘉义县", - "children": [ - { "value": "4878", "label": "嘉义县" } - ] - }, { - "value": "4879", - "label": "台南县", - "children": [ - { "value": "4880", "label": "台南县" } - ] - }, { - "value": "4881", - "label": "高雄县", - "children": [ - { "value": "4882", "label": "高雄县" } - ] - }, { - "value": "4883", - "label": "屏东县", - "children": [ - { "value": "4884", "label": "屏东县" } - ] - }, { - "value": "4885", - "label": "台东县", - "children": [ - { "value": "4886", "label": "台东县" } - ] - }, { - "value": "4887", - "label": "花莲县", - "children": [ - { "value": "4888", "label": "花莲县" } - ] - }, { - "value": "4889", - "label": "澎湖县", - "children": [ - { "value": "4890", "label": "澎湖县" } - ] - }, { - "value": "4891", - "label": "基隆市", - "children": [ - { "value": "4892", "label": "基隆市" }, - { "value": "710701", "label": "仁爱区" }, - { "value": "710702", "label": "信义区" }, - { "value": "710703", "label": "中正区" }, - { "value": "710705", "label": "安乐区" }, - { "value": "710706", "label": "暖暖区" }, - { "value": "710707", "label": "七堵区" }, - { "value": "710708", "label": "其它区" } - ] - }, { - "value": "4893", - "label": "新竹市", - "children": [ - { "value": "4894", "label": "新竹市" }, - { "value": "710802", "label": "北区" }, - { "value": "710803", "label": "香山区" }, - { "value": "710804", "label": "其它区" } - ] - }, { - "value": "4895", - "label": "台中市", - "children": [ - { "value": "4896", "label": "台中市" }, - { "value": "710401", "label": "中区" }, - { "value": "710403", "label": "南区" }, - { "value": "710405", "label": "北区" }, - { "value": "710406", "label": "北屯区" }, - { "value": "710407", "label": "西屯区" }, - { "value": "710408", "label": "南屯区" }, - { "value": "710409", "label": "其它区" } - ] - }, { - "value": "4897", - "label": "嘉义市", - "children": [ - { "value": "4898", "label": "嘉义市" }, - { "value": "710903", "label": "其它区" } - ] - }, { - "value": "4899", - "label": "台南市", - "children": [ - { "value": "4900", "label": "台南市" }, - { "value": "710301", "label": "中西区" }, - { "value": "710303", "label": "南区" }, - { "value": "710304", "label": "北区" }, - { "value": "710305", "label": "安平区" }, - { "value": "710306", "label": "安南区" }, - { "value": "710307", "label": "其它区" } - ] - }, { - "value": "4901", - "label": "台北市", - "children": [ - { "value": "4902", "label": "台北市" }, - { "value": "710101", "label": "中正区" }, - { "value": "710106", "label": "万华区" }, - { "value": "710107", "label": "信义区" }, - { "value": "710108", "label": "士林区" }, - { "value": "710109", "label": "北投区" }, - { "value": "710110", "label": "内湖区" }, - { "value": "710111", "label": "南港区" }, - { "value": "710112", "label": "文山区" }, - { "value": "710113", "label": "其它区" } - ] - }, { - "value": "4903", - "label": "高雄市", - "children": [ - { "value": "4904", "label": "高雄市" }, - { "value": "710202", "label": "前金区" }, - { "value": "710203", "label": "芩雅区" }, - { "value": "710204", "label": "盐埕区" }, - { "value": "710205", "label": "鼓山区" }, - { "value": "710206", "label": "旗津区" }, - { "value": "710207", "label": "前镇区" }, - { "value": "710208", "label": "三民区" }, - { "value": "710209", "label": "左营区" }, - { "value": "710210", "label": "楠梓区" }, - { "value": "710211", "label": "小港区" }, - { "value": "710212", "label": "其它区" } - ] - }, { - "value": "4905", - "label": "金门县", - "children": [ - { "value": "4906", "label": "金门" } - ] - }, { - "value": "4907", - "label": "连江县", - "children": [ - { "value": "4908", "label": "连江" } - ] - }, { - "value": "4908", - "label": "新北市", - "children": [ - { "value": "711100", "label": "新北市" } - ] - }], - "value": "4858", - "label": "台湾" - }]; - - if (hasDisabled) { - dataSource[1].disabled = true; - } - - return dataSource; -}; - -function render(lang = 'en-us') { - const i18n = i18nMap[lang]; - const dataSource = createDataSource(i18n.option); - const disabledDataSource = createDataSource(i18n.option, true); - - ReactDOM.render(( -
    - - - - - - - - - - - - - - - - -
    - ), document.getElementById('container')); -} - -window.renderDemo = function(lang) { - render(lang); -}; - -window.renderDemo(); - -initDemo('cascader'); diff --git a/components/cascader/__docs__/theme/index.tsx b/components/cascader/__docs__/theme/index.tsx new file mode 100644 index 0000000000..bc10393c85 --- /dev/null +++ b/components/cascader/__docs__/theme/index.tsx @@ -0,0 +1,6128 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import '../../style'; +import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; +import Cascader from '../../index'; +import { CascaderDataItem } from '../../types'; + +const i18nMap = { + 'en-us': { + option: 'Option', + }, + 'zh-cn': { + option: '选项', + }, +}; + +const createDataSource = (label: unknown, hasDisabled?: boolean) => { + const dataSource: CascaderDataItem[] = [ + { + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + { value: '2977', label: '蓝田县' }, + { value: '2978', label: '户县' }, + { value: '2979', label: '周至县' }, + { value: '4208', label: '灞桥区' }, + { value: '4209', label: '长安区' }, + { value: '4210', label: '莲湖区' }, + { value: '4211', label: '临潼区' }, + { value: '4212', label: '未央区' }, + { value: '4213', label: '新城区' }, + { value: '4214', label: '阎良区' }, + { value: '4215', label: '雁塔区' }, + { value: '4388', label: '碑林区' }, + { value: '610127', label: '其它区' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + { value: '4204', label: '王益区' }, + { value: '4205', label: '耀州区' }, + { value: '4206', label: '印台区' }, + { value: '610223', label: '其它区' }, + ], + }, + { + value: '2983', + label: '宝鸡', + children: [ + { value: '2984', label: '宝鸡市' }, + { value: '2986', label: '岐山县' }, + { value: '2987', label: '凤翔县' }, + { value: '2989', label: '太白县' }, + { value: '2990', label: '麟游县' }, + { value: '2991', label: '扶风县' }, + { value: '2992', label: '千阳县' }, + { value: '2993', label: '眉县' }, + { value: '2994', label: '凤县' }, + { value: '2988', label: '陇县' }, + { value: '4200', label: '陈仓区' }, + { value: '4201', label: '渭滨区' }, + { value: '4387', label: '金台区' }, + { value: '610332', label: '其它区' }, + ], + }, + { + value: '2995', + label: '咸阳', + children: [ + { value: '2996', label: '咸阳市' }, + { value: '2997', label: '兴平市' }, + { value: '2998', label: '礼泉县' }, + { value: '2999', label: '泾阳县' }, + { value: '3001', label: '三原县' }, + { value: '3002', label: '彬县' }, + { value: '3003', label: '旬邑县' }, + { value: '3004', label: '长武县' }, + { value: '3005', label: '乾县' }, + { value: '3006', label: '武功县' }, + { value: '3007', label: '淳化县' }, + { value: '3000', label: '永寿县' }, + { value: '4216', label: '秦都区' }, + { value: '4217', label: '渭城区' }, + { value: '4218', label: '杨凌区' }, + { value: '610482', label: '其它区' }, + ], + }, + { + value: '3008', + label: '渭南', + children: [ + { value: '3009', label: '渭南市' }, + { value: '3010', label: '韩城市' }, + { value: '3011', label: '华阴市' }, + { value: '3013', label: '潼关县' }, + { value: '3014', label: '白水县' }, + { value: '3015', label: '澄城县' }, + { value: '3016', label: '华县' }, + { value: '3017', label: '合阳县' }, + { value: '3018', label: '富平县' }, + { value: '3019', label: '大荔县' }, + { value: '3012', label: '蒲城县' }, + { value: '4207', label: '临渭区' }, + { value: '610583', label: '其它区' }, + ], + }, + { + value: '3020', + label: '延安', + children: [ + { value: '3021', label: '延安市' }, + { value: '3022', label: '安塞县' }, + { value: '3023', label: '洛川县' }, + { value: '3024', label: '子长县' }, + { value: '3025', label: '黄陵县' }, + { value: '3026', label: '延川县' }, + { value: '3027', label: '富县' }, + { value: '3028', label: '延长县' }, + { value: '3029', label: '甘泉县' }, + { value: '3030', label: '宜川县' }, + { value: '3031', label: '志丹县' }, + { value: '3032', label: '黄龙县' }, + { value: '3033', label: '吴起县' }, + { value: '4219', label: '宝塔区' }, + { value: '610633', label: '其它区' }, + ], + }, + { + value: '3034', + label: '汉中', + children: [ + { value: '3035', label: '汉中市' }, + { value: '3036', label: '留坝县' }, + { value: '3037', label: '镇巴县' }, + { value: '3038', label: '城固县' }, + { value: '3039', label: '南郑县' }, + { value: '3040', label: '洋县' }, + { value: '3041', label: '宁强县' }, + { value: '3042', label: '佛坪县' }, + { value: '3043', label: '勉县' }, + { value: '3044', label: '西乡县' }, + { value: '3045', label: '略阳县' }, + { value: '4202', label: '汉台区' }, + { value: '610731', label: '其它区' }, + ], + }, + { + value: '3046', + label: '榆林', + children: [ + { value: '3047', label: '榆林市' }, + { value: '3048', label: '清涧县' }, + { value: '3049', label: '绥德县' }, + { value: '3050', label: '神木县' }, + { value: '3051', label: '佳县' }, + { value: '3053', label: '子洲县' }, + { value: '3054', label: '靖边县' }, + { value: '3055', label: '横山县' }, + { value: '3056', label: '米脂县' }, + { value: '3057', label: '吴堡县' }, + { value: '3058', label: '定边县' }, + { value: '3052', label: '府谷县' }, + { value: '4220', label: '榆阳区' }, + { value: '610832', label: '其它区' }, + ], + }, + { + value: '3059', + label: '安康', + children: [ + { value: '3060', label: '安康市' }, + { value: '3061', label: '紫阳县' }, + { value: '3062', label: '岚皋县' }, + { value: '3063', label: '旬阳县' }, + { value: '3065', label: '平利县' }, + { value: '3066', label: '石泉县' }, + { value: '3067', label: '宁陕县' }, + { value: '3068', label: '白河县' }, + { value: '3069', label: '汉阴县' }, + { value: '3064', label: '镇坪县' }, + { value: '4199', label: '汉滨区' }, + { value: '610930', label: '其它区' }, + ], + }, + { + value: '3070', + label: '商洛', + children: [ + { value: '3071', label: '商洛市' }, + { value: '3072', label: '镇安县' }, + { value: '3073', label: '山阳县' }, + { value: '3074', label: '洛南县' }, + { value: '3075', label: '商南县' }, + { value: '3076', label: '丹凤县' }, + { value: '3077', label: '柞水县' }, + { value: '4203', label: '商州区' }, + { value: '611027', label: '其它区' }, + ], + }, + ], + value: '2973', + label: '陕西', + }, + { + children: [ + { + value: '3079', + label: '成都', + children: [ + { value: '3080', label: '成都市' }, + { value: '3081', label: '都江堰市' }, + { value: '3082', label: '彭州市' }, + { value: '3083', label: '邛崃市' }, + { value: '3084', label: '崇州市' }, + { value: '3085', label: '金堂县' }, + { value: '3086', label: '郫县' }, + { value: '3087', label: '新津县' }, + { value: '3088', label: '双流县' }, + { value: '3089', label: '蒲江县' }, + { value: '3090', label: '大邑县' }, + { value: '4240', label: '成华区' }, + { value: '4241', label: '金牛区' }, + { value: '4242', label: '锦江区' }, + { value: '4243', label: '龙泉驿区' }, + { value: '4244', label: '青白江区' }, + { value: '4245', label: '青羊区' }, + { value: '4246', label: '温江区' }, + { value: '4247', label: '武侯区' }, + { value: '4248', label: '新都区' }, + { value: '510185', label: '其它区' }, + ], + }, + { + value: '3091', + label: '自贡', + children: [ + { value: '3092', label: '自贡市' }, + { value: '3093', label: '荣县' }, + { value: '3094', label: '富顺县' }, + { value: '4278', label: '大安区' }, + { value: '4279', label: '贡井区' }, + { value: '4280', label: '沿滩区' }, + { value: '4281', label: '自流井区' }, + { value: '510323', label: '其它区' }, + ], + }, + { + value: '3095', + label: '攀枝花', + children: [ + { value: '3096', label: '攀枝花市' }, + { value: '3097', label: '米易县' }, + { value: '3098', label: '盐边县' }, + { value: '4270', label: '东区' }, + { value: '4271', label: '仁和区' }, + { value: '4272', label: '西区' }, + { value: '510423', label: '其它区' }, + ], + }, + { + value: '3099', + label: '泸州', + children: [ + { value: '3100', label: '泸州市' }, + { value: '3101', label: '泸县' }, + { value: '3102', label: '合江县' }, + { value: '3103', label: '叙永县' }, + { value: '3104', label: '古蔺县' }, + { value: '4259', label: '江阳区' }, + { value: '4260', label: '龙马潭区' }, + { value: '4261', label: '纳溪区' }, + { value: '510526', label: '其它区' }, + ], + }, + { + value: '3105', + label: '德阳', + children: [ + { value: '3106', label: '德阳市' }, + { value: '3107', label: '广汉市' }, + { value: '3108', label: '什邡市' }, + { value: '3109', label: '绵竹市' }, + { value: '3110', label: '罗江县' }, + { value: '3111', label: '中江县' }, + { value: '4250', label: '旌阳区' }, + { value: '510684', label: '其它区' }, + ], + }, + { + value: '3112', + label: '绵阳', + children: [ + { value: '3113', label: '绵阳市' }, + { value: '3114', label: '江油市' }, + { value: '3115', label: '盐亭县' }, + { value: '3116', label: '三台县' }, + { value: '3117', label: '平武县' }, + { value: '3118', label: '北川羌族自治县' }, + { value: '3119', label: '安县' }, + { value: '3120', label: '梓潼县' }, + { value: '4263', label: '涪城区' }, + { value: '4264', label: '游仙区' }, + { value: '510782', label: '其它区' }, + ], + }, + { + value: '3121', + label: '广元', + children: [ + { value: '3122', label: '广元市' }, + { value: '3123', label: '青川县' }, + { value: '3124', label: '旺苍县' }, + { value: '3125', label: '剑阁县' }, + { value: '3126', label: '苍溪县' }, + { value: '4252', label: '朝天区' }, + { value: '4253', label: '市中区' }, + { value: '4254', label: '元坝区' }, + { value: '510802', label: '利州区' }, + { value: '510825', label: '其它区' }, + ], + }, + { + value: '3127', + label: '遂宁', + children: [ + { value: '3128', label: '遂宁市' }, + { value: '3129', label: '射洪县' }, + { value: '3131', label: '大英县' }, + { value: '3130', label: '蓬溪县' }, + { value: '4273', label: '安居区' }, + { value: '4274', label: '船山区' }, + { value: '510924', label: '其它区' }, + ], + }, + { + value: '3132', + label: '内江', + children: [ + { value: '3133', label: '内江市' }, + { value: '3134', label: '资中县' }, + { value: '3135', label: '隆昌县' }, + { value: '3136', label: '威远县' }, + { value: '4265', label: '东兴区' }, + { value: '4266', label: '市中区' }, + { value: '511029', label: '其它区' }, + ], + }, + { + value: '3137', + label: '乐山', + children: [ + { value: '3138', label: '乐山市' }, + { value: '3139', label: '峨眉山市' }, + { value: '3140', label: '夹江县' }, + { value: '3141', label: '井研县' }, + { value: '3142', label: '犍为县' }, + { value: '3144', label: '马边彝族自治县' }, + { value: '3145', label: '峨边彝族自治县' }, + { value: '3143', label: '沐川县' }, + { value: '4255', label: '金口河区' }, + { value: '4256', label: '沙湾区' }, + { value: '4257', label: '市中区' }, + { value: '4258', label: '五通桥区' }, + { value: '511182', label: '其它区' }, + ], + }, + { + value: '3146', + label: '南充', + children: [ + { value: '3147', label: '南充市' }, + { value: '3148', label: '阆中市' }, + { value: '3149', label: '营山县' }, + { value: '3150', label: '蓬安县' }, + { value: '3151', label: '仪陇县' }, + { value: '3152', label: '南部县' }, + { value: '3153', label: '西充县' }, + { value: '4267', label: '高坪区' }, + { value: '4268', label: '嘉陵区' }, + { value: '4269', label: '顺庆区' }, + { value: '511382', label: '其它区' }, + ], + }, + { + value: '3154', + label: '宜宾', + children: [ + { value: '3155', label: '宜宾市' }, + { value: '3156', label: '宜宾县' }, + { value: '3157', label: '兴文县' }, + { value: '3158', label: '南溪县' }, + { value: '3159', label: '珙县' }, + { value: '3160', label: '长宁县' }, + { value: '3161', label: '高县' }, + { value: '3162', label: '江安县' }, + { value: '3163', label: '筠连县' }, + { value: '3164', label: '屏山县' }, + { value: '4276', label: '翠屏区' }, + { value: '511530', label: '其它区' }, + ], + }, + { + value: '3165', + label: '广安', + children: [ + { value: '3166', label: '广安市' }, + { value: '3167', label: '华蓥市' }, + { value: '3168', label: '岳池县' }, + { value: '3169', label: '邻水县' }, + { value: '3170', label: '武胜县' }, + { value: '4251', label: '广安区' }, + { value: '511682', label: '市辖区' }, + { value: '511683', label: '其它区' }, + ], + }, + { + value: '3171', + label: '达州', + children: [ + { value: '3172', label: '达州市' }, + { value: '3173', label: '万源市' }, + { value: '3174', label: '达县' }, + { value: '3175', label: '渠县' }, + { value: '3176', label: '宣汉县' }, + { value: '3177', label: '开江县' }, + { value: '3178', label: '大竹县' }, + { value: '4249', label: '通川区' }, + { value: '511782', label: '其它区' }, + ], + }, + { + value: '3179', + label: '巴中', + children: [ + { value: '3180', label: '巴中市' }, + { value: '3181', label: '南江县' }, + { value: '3182', label: '平昌县' }, + { value: '3183', label: '通江县' }, + { value: '4239', label: '巴州区' }, + { value: '511924', label: '其它区' }, + ], + }, + { + value: '3184', + label: '雅安', + children: [ + { value: '3185', label: '雅安市' }, + { value: '3186', label: '芦山县' }, + { value: '3187', label: '石棉县' }, + { value: '3188', label: '名山县' }, + { value: '3189', label: '天全县' }, + { value: '3190', label: '荥经县' }, + { value: '3191', label: '宝兴县' }, + { value: '3192', label: '汉源县' }, + { value: '4275', label: '雨城区' }, + { value: '511828', label: '其它区' }, + ], + }, + { + value: '3193', + label: '眉山', + children: [ + { value: '3194', label: '眉山市' }, + { value: '3195', label: '仁寿县' }, + { value: '3197', label: '洪雅县' }, + { value: '3198', label: '丹棱县' }, + { value: '3199', label: '青神县' }, + { value: '3196', label: '彭山县' }, + { value: '4262', label: '东坡区' }, + { value: '511426', label: '其它区' }, + ], + }, + { + value: '3200', + label: '资阳', + children: [ + { value: '3201', label: '资阳市' }, + { value: '3202', label: '简阳市' }, + { value: '3203', label: '安岳县' }, + { value: '3204', label: '乐至县' }, + { value: '4277', label: '雁江区' }, + { value: '512082', label: '其它区' }, + ], + }, + { + value: '3205', + label: '阿坝藏族羌族自治州', + children: [ + { value: '3206', label: '马尔康县' }, + { value: '3207', label: '九寨沟县' }, + { value: '3208', label: '红原县' }, + { value: '3209', label: '汶川县' }, + { value: '3211', label: '理县' }, + { value: '3212', label: '若尔盖县' }, + { value: '3213', label: '小金县' }, + { value: '3214', label: '黑水县' }, + { value: '3215', label: '金川县' }, + { value: '3216', label: '松潘县' }, + { value: '3217', label: '壤塘县' }, + { value: '3218', label: '茂县' }, + { value: '3210', label: '阿坝县' }, + ], + }, + { + value: '3219', + label: '甘孜藏族自治州', + children: [ + { value: '3220', label: '康定县' }, + { value: '3221', label: '丹巴县' }, + { value: '3222', label: '炉霍县' }, + { value: '3223', label: '九龙县' }, + { value: '3224', label: '甘孜县' }, + { value: '3225', label: '雅江县' }, + { value: '3226', label: '新龙县' }, + { value: '3227', label: '道孚县' }, + { value: '3228', label: '白玉县' }, + { value: '3229', label: '理塘县' }, + { value: '3230', label: '德格县' }, + { value: '3231', label: '乡城县' }, + { value: '3232', label: '石渠县' }, + { value: '3233', label: '稻城县' }, + { value: '3234', label: '色达县' }, + { value: '3235', label: '巴塘县' }, + { value: '3236', label: '泸定县' }, + { value: '3237', label: '得荣县' }, + ], + }, + { + value: '3238', + label: '凉山彝族自治州', + children: [ + { value: '3239', label: '西昌市' }, + { value: '3240', label: '美姑县' }, + { value: '3241', label: '昭觉县' }, + { value: '3242', label: '金阳县' }, + { value: '3243', label: '甘洛县' }, + { value: '3244', label: '布拖县' }, + { value: '3245', label: '雷波县' }, + { value: '3246', label: '普格县' }, + { value: '3247', label: '宁南县' }, + { value: '3248', label: '喜德县' }, + { value: '3249', label: '会东县' }, + { value: '3250', label: '越西县' }, + { value: '3251', label: '会理县' }, + { value: '3252', label: '盐源县' }, + { value: '3253', label: '德昌县' }, + { value: '3254', label: '冕宁县' }, + { value: '3255', label: '木里藏族自治县' }, + ], + }, + ], + value: '3078', + label: '四川', + }, + { + children: [ + { + value: '3257', + label: '天津', + children: [ + { value: '3258', label: '天津市' }, + { value: '3259', label: '静海县' }, + { value: '3260', label: '宁河县' }, + { value: '3261', label: '蓟县' }, + { value: '4282', label: '宝坻区' }, + { value: '4283', label: '北辰区' }, + { value: '4284', label: '大港区' }, + { value: '4285', label: '东丽区' }, + { value: '4286', label: '汉沽区' }, + { value: '4287', label: '和平区' }, + { value: '4288', label: '河北区' }, + { value: '4289', label: '河东区' }, + { value: '4290', label: '河西区' }, + { value: '4291', label: '红桥区' }, + { value: '4292', label: '津南区' }, + { value: '4293', label: '南开区' }, + { value: '4294', label: '塘沽区' }, + { value: '4295', label: '武清区' }, + { value: '4296', label: '西青区' }, + { value: '10005', label: '滨海新区' }, + { value: '120226', label: '其它区' }, + ], + }, + ], + value: '3256', + label: '天津', + }, + { + children: [ + { + value: '3291', + label: '拉萨', + children: [ + { value: '3292', label: '拉萨市' }, + { value: '3293', label: '林周县' }, + { value: '3294', label: '达孜县' }, + { value: '3295', label: '尼木县' }, + { value: '3296', label: '当雄县' }, + { value: '3297', label: '曲水县' }, + { value: '3298', label: '墨竹工卡县' }, + { value: '3299', label: '堆龙德庆县' }, + { value: '4297', label: '城关区' }, + { value: '540128', label: '其它区' }, + ], + }, + { + value: '3300', + label: '那曲', + children: [ + { value: '3301', label: '那曲县' }, + { value: '3302', label: '嘉黎县' }, + { value: '3303', label: '申扎县' }, + { value: '3304', label: '巴青县' }, + { value: '3305', label: '聂荣县' }, + { value: '3306', label: '尼玛县' }, + { value: '3307', label: '比如县' }, + { value: '3308', label: '索县' }, + { value: '3309', label: '班戈县' }, + { value: '3310', label: '安多县' }, + ], + }, + { + value: '3311', + label: '昌都', + children: [ + { value: '3312', label: '昌都县' }, + { value: '3313', label: '芒康县' }, + { value: '3314', label: '贡觉县' }, + { value: '3315', label: '八宿县' }, + { value: '3316', label: '左贡县' }, + { value: '3317', label: '边坝县' }, + { value: '3318', label: '洛隆县' }, + { value: '3319', label: '江达县' }, + { value: '3320', label: '类乌齐县' }, + { value: '3321', label: '丁青县' }, + { value: '3322', label: '察雅县' }, + ], + }, + { + value: '3323', + label: '山南', + children: [ + { value: '3324', label: '乃东县' }, + { value: '3325', label: '琼结县' }, + { value: '3326', label: '措美县' }, + { value: '3327', label: '加查县' }, + { value: '3328', label: '贡嘎县' }, + { value: '3329', label: '洛扎县' }, + { value: '3330', label: '曲松县' }, + { value: '3331', label: '桑日县' }, + { value: '3332', label: '扎囊县' }, + { value: '3333', label: '错那县' }, + { value: '3335', label: '浪卡子县' }, + { value: '3334', label: '隆子县' }, + ], + }, + { + value: '3336', + label: '日喀则', + children: [ + { value: '3337', label: '日喀则市' }, + { value: '3338', label: '定结县' }, + { value: '3339', label: '萨迦县' }, + { value: '3340', label: '江孜县' }, + { value: '3341', label: '拉孜县' }, + { value: '3342', label: '定日县' }, + { value: '3343', label: '康马县' }, + { value: '3344', label: '聂拉木县' }, + { value: '3345', label: '吉隆县' }, + { value: '3347', label: '谢通门县' }, + { value: '3348', label: '昂仁县' }, + { value: '3349', label: '岗巴县' }, + { value: '3350', label: '仲巴县' }, + { value: '3351', label: '萨嘎县' }, + { value: '3352', label: '仁布县' }, + { value: '3353', label: '白朗县' }, + { value: '3354', label: '南木林县' }, + { value: '3346', label: '亚东县' }, + ], + }, + { + value: '3355', + label: '阿里', + children: [ + { value: '3356', label: '噶尔县' }, + { value: '3357', label: '措勤县' }, + { value: '3358', label: '普兰县' }, + { value: '3359', label: '革吉县' }, + { value: '3360', label: '日土县' }, + { value: '3361', label: '札达县' }, + { value: '3362', label: '改则县' }, + ], + }, + { + value: '3363', + label: '林芝', + children: [ + { value: '3364', label: '林芝县' }, + { value: '3365', label: '墨脱县' }, + { value: '3366', label: '朗县' }, + { value: '3367', label: '米林县' }, + { value: '3368', label: '察隅县' }, + { value: '3369', label: '波密县' }, + { value: '3370', label: '工布江达县' }, + ], + }, + ], + value: '3290', + label: '西藏', + }, + { + children: [ + { + value: '3372', + label: '乌鲁木齐', + children: [ + { value: '3373', label: '乌鲁木齐市' }, + { value: '3374', label: '乌鲁木齐县' }, + { value: '4302', label: '达坂城区' }, + { value: '4303', label: '东山区' }, + { value: '4304', label: '沙依巴克区' }, + { value: '4305', label: '水磨沟区' }, + { value: '4306', label: '天山区' }, + { value: '4307', label: '头屯河区' }, + { value: '4308', label: '新市区' }, + { value: '650109', label: '米东区' }, + { value: '650122', label: '其它区' }, + ], + }, + { + value: '3375', + label: '克拉玛依', + children: [ + { value: '3376', label: '克拉玛依市' }, + { value: '4298', label: '白碱滩区' }, + { value: '4299', label: '独山子区' }, + { value: '4300', label: '克拉玛依区' }, + { value: '4301', label: '乌尔禾区' }, + { value: '650206', label: '其它区' }, + ], + }, + { + value: '3377', + label: '石河子', + children: [{ value: '3378', label: '石河子市' }], + }, + { + value: '3379', + label: '阿拉尔', + children: [{ value: '3380', label: '阿拉尔市' }], + }, + { + value: '3381', + label: '图木舒克', + children: [{ value: '3382', label: '图木舒克市' }], + }, + { + value: '3383', + label: '五家渠', + children: [{ value: '3384', label: '五家渠市' }], + }, + { + value: '3385', + label: '吐鲁番', + children: [ + { value: '3386', label: '吐鲁番市' }, + { value: '3387', label: '托克逊县' }, + { value: '3388', label: '鄯善县' }, + ], + }, + { + value: '3389', + label: '哈密', + children: [ + { value: '3390', label: '哈密市' }, + { value: '3391', label: '伊吾县' }, + { value: '3392', label: '巴里坤哈萨克自治县' }, + ], + }, + { + value: '3393', + label: '和田', + children: [ + { value: '3394', label: '和田市' }, + { value: '3395', label: '和田县' }, + { value: '3396', label: '洛浦县' }, + { value: '3397', label: '民丰县' }, + { value: '3398', label: '皮山县' }, + { value: '3399', label: '策勒县' }, + { value: '3401', label: '墨玉县' }, + { value: '3400', label: '于田县' }, + ], + }, + { + value: '3402', + label: '阿克苏', + children: [ + { value: '3403', label: '阿克苏市' }, + { value: '3404', label: '温宿县' }, + { value: '3405', label: '沙雅县' }, + { value: '3406', label: '拜城县' }, + { value: '3407', label: '阿瓦提县' }, + { value: '3408', label: '库车县' }, + { value: '3409', label: '柯坪县' }, + { value: '3410', label: '新和县' }, + { value: '3411', label: '乌什县' }, + ], + }, + { + value: '3412', + label: '喀什', + children: [ + { value: '3413', label: '喀什市' }, + { value: '3414', label: '巴楚县' }, + { value: '3415', label: '泽普县' }, + { value: '3416', label: '伽师县' }, + { value: '3417', label: '叶城县' }, + { value: '3418', label: '岳普湖县' }, + { value: '3419', label: '疏勒县' }, + { value: '3420', label: '麦盖提县' }, + { value: '3421', label: '英吉沙县' }, + { value: '3422', label: '莎车县' }, + { value: '3423', label: '疏附县' }, + { value: '3424', label: '塔什库尔干塔吉克自治县' }, + ], + }, + { + value: '3425', + label: '克孜勒苏柯尔克孜自治州', + children: [ + { value: '3426', label: '阿图什市' }, + { value: '3427', label: '阿合奇县' }, + { value: '3428', label: '乌恰县' }, + { value: '3429', label: '阿克陶县' }, + ], + }, + { + value: '3430', + label: '巴音郭楞蒙古自治州', + children: [ + { value: '3431', label: '库尔勒市' }, + { value: '3432', label: '和静县' }, + { value: '3433', label: '尉犁县' }, + { value: '3434', label: '和硕县' }, + { value: '3435', label: '且末县' }, + { value: '3436', label: '博湖县' }, + { value: '3437', label: '轮台县' }, + { value: '3438', label: '若羌县' }, + { value: '3439', label: '焉耆回族自治县' }, + ], + }, + { + value: '3440', + label: '昌吉回族自治州', + children: [ + { value: '3441', label: '昌吉市' }, + { value: '3442', label: '阜康市' }, + { value: '3443', label: '米泉市' }, + { value: '3444', label: '奇台县' }, + { value: '3445', label: '玛纳斯县' }, + { value: '3446', label: '吉木萨尔县' }, + { value: '3447', label: '呼图壁县' }, + { value: '3448', label: '木垒哈萨克自治县' }, + ], + }, + { + value: '3449', + label: '博尔塔拉蒙古自治州', + children: [ + { value: '3450', label: '博乐市' }, + { value: '3451', label: '精河县' }, + { value: '3452', label: '温泉县' }, + ], + }, + { + value: '3453', + label: '伊犁哈萨克自治州', + children: [ + { value: '3455', label: '奎屯市' }, + { value: '3456', label: '伊宁县' }, + { value: '3457', label: '特克斯县' }, + { value: '3458', label: '尼勒克县' }, + { value: '3459', label: '昭苏县' }, + { value: '3460', label: '新源县' }, + { value: '3461', label: '霍城县' }, + { value: '3462', label: '巩留县' }, + { value: '3463', label: '察布查尔锡伯自治县' }, + { value: '3454', label: '伊宁市' }, + ], + }, + { + value: '3781', + label: '阿勒泰地区', + children: [ + { value: '3471', label: '阿勒泰市' }, + { value: '3472', label: '青河县' }, + { value: '3473', label: '吉木乃县' }, + { value: '3474', label: '富蕴县' }, + { value: '3475', label: '布尔津县' }, + { value: '3476', label: '福海县' }, + { value: '3477', label: '哈巴河县' }, + ], + }, + { + value: '3782', + label: '塔城地区', + children: [ + { value: '3464', label: '塔城市' }, + { value: '3465', label: '乌苏市' }, + { value: '3466', label: '额敏县' }, + { value: '3468', label: '沙湾县' }, + { value: '3469', label: '托里县' }, + { value: '3470', label: '和布克赛尔蒙古自治县' }, + { value: '3467', label: '裕民县' }, + ], + }, + ], + value: '3371', + label: '新疆', + }, + { + children: [ + { + value: '3479', + label: '杭州', + children: [ + { value: '3480', label: '杭州市' }, + { value: '3481', label: '建德市' }, + { value: '3482', label: '富阳市' }, + { value: '3483', label: '临安市' }, + { value: '3484', label: '桐庐县' }, + { value: '3485', label: '淳安县' }, + { value: '4319', label: '滨江区' }, + { value: '4320', label: '拱墅区' }, + { value: '4321', label: '江干区' }, + { value: '4322', label: '上城区' }, + { value: '4323', label: '西湖区' }, + { value: '4324', label: '下城区' }, + { value: '4325', label: '萧山区' }, + { value: '4326', label: '余杭区' }, + { value: '330186', label: '其它区' }, + ], + }, + { + value: '3486', + label: '宁波', + children: [ + { value: '3487', label: '宁波市' }, + { value: '3488', label: '余姚市' }, + { value: '3489', label: '慈溪市' }, + { value: '3490', label: '奉化市' }, + { value: '3491', label: '宁海县' }, + { value: '3492', label: '象山县' }, + { value: '4334', label: '北仑区' }, + { value: '4335', label: '海曙区' }, + { value: '4336', label: '江北区' }, + { value: '4337', label: '江东区' }, + { value: '4338', label: '鄞州区' }, + { value: '4339', label: '镇海区' }, + { value: '330284', label: '其它区' }, + ], + }, + { + value: '3493', + label: '温州', + children: [ + { value: '3494', label: '温州市' }, + { value: '3495', label: '瑞安市' }, + { value: '3496', label: '乐清市' }, + { value: '3497', label: '永嘉县' }, + { value: '3498', label: '洞头县' }, + { value: '3499', label: '平阳县' }, + { value: '3500', label: '苍南县' }, + { value: '3501', label: '文成县' }, + { value: '3502', label: '泰顺县' }, + { value: '4346', label: '龙湾区' }, + { value: '4347', label: '鹿城区' }, + { value: '4348', label: '瓯海区' }, + { value: '330383', label: '其它区' }, + ], + }, + { + value: '3503', + label: '嘉兴', + children: [ + { value: '3504', label: '嘉兴市' }, + { value: '3505', label: '海宁市' }, + { value: '3506', label: '平湖市' }, + { value: '3507', label: '桐乡市' }, + { value: '3508', label: '嘉善县' }, + { value: '3509', label: '海盐县' }, + { value: '4329', label: '秀城区' }, + { value: '4330', label: '秀洲区' }, + { value: '330402', label: '南湖区' }, + { value: '330484', label: '其它区' }, + ], + }, + { + value: '3510', + label: '湖州', + children: [ + { value: '3511', label: '湖州市' }, + { value: '3512', label: '长兴县' }, + { value: '3513', label: '德清县' }, + { value: '3514', label: '安吉县' }, + { value: '4327', label: '南浔区' }, + { value: '4328', label: '吴兴区' }, + { value: '330524', label: '其它区' }, + ], + }, + { + value: '3515', + label: '绍兴', + children: [ + { value: '3516', label: '绍兴市' }, + { value: '3517', label: '诸暨市' }, + { value: '3519', label: '嵊州市' }, + { value: '3520', label: '绍兴县' }, + { value: '3521', label: '新昌县' }, + { value: '3518', label: '上虞市' }, + { value: '4342', label: '越城区' }, + { value: '330684', label: '其它区' }, + ], + }, + { + value: '3522', + label: '金华', + children: [ + { value: '3523', label: '金华市' }, + { value: '3524', label: '兰溪市' }, + { value: '3525', label: '义乌市' }, + { value: '3526', label: '东阳市' }, + { value: '3527', label: '永康市' }, + { value: '3528', label: '武义县' }, + { value: '3529', label: '浦江县' }, + { value: '3530', label: '磐安县' }, + { value: '4331', label: '金东区' }, + { value: '4332', label: '婺城区' }, + { value: '330785', label: '其它区' }, + ], + }, + { + value: '3531', + label: '衢州', + children: [ + { value: '3532', label: '衢州市' }, + { value: '3533', label: '江山市' }, + { value: '3534', label: '龙游县' }, + { value: '3535', label: '常山县' }, + { value: '3536', label: '开化县' }, + { value: '4340', label: '柯城区' }, + { value: '4341', label: '衢江区' }, + { value: '330882', label: '其它区' }, + ], + }, + { + value: '3537', + label: '舟山', + children: [ + { value: '3538', label: '舟山市' }, + { value: '3539', label: '岱山县' }, + { value: '3540', label: '嵊泗县' }, + { value: '4349', label: '定海区' }, + { value: '4350', label: '普陀区' }, + { value: '330923', label: '其它区' }, + ], + }, + { + value: '3541', + label: '台州', + children: [ + { value: '3542', label: '台州市' }, + { value: '3544', label: '温岭市' }, + { value: '3545', label: '玉环县' }, + { value: '3546', label: '天台县' }, + { value: '3547', label: '仙居县' }, + { value: '3548', label: '三门县' }, + { value: '3543', label: '临海市' }, + { value: '4343', label: '黄岩区' }, + { value: '4344', label: '椒江区' }, + { value: '4345', label: '路桥区' }, + { value: '331083', label: '其它区' }, + ], + }, + { + value: '3549', + label: '丽水', + children: [ + { value: '3550', label: '丽水市' }, + { value: '3551', label: '龙泉市' }, + { value: '3552', label: '缙云县' }, + { value: '3553', label: '青田县' }, + { value: '3554', label: '云和县' }, + { value: '3555', label: '遂昌县' }, + { value: '3556', label: '松阳县' }, + { value: '3557', label: '庆元县' }, + { value: '3558', label: '景宁畲族自治县' }, + { value: '4333', label: '莲都区' }, + { value: '331182', label: '其它区' }, + ], + }, + ], + value: '3478', + label: '浙江', + }, + { + children: [ + { + value: '3560', + label: '昆明', + children: [ + { value: '3561', label: '昆明市' }, + { value: '3562', label: '安宁市' }, + { value: '3563', label: '富民县' }, + { value: '3564', label: '嵩明县' }, + { value: '3565', label: '呈贡县' }, + { value: '3566', label: '晋宁县' }, + { value: '3567', label: '宜良县' }, + { value: '3568', label: '禄劝彝族苗族自治县' }, + { value: '3569', label: '石林彝族自治县' }, + { value: '3570', label: '寻甸回族自治县' }, + { value: '4310', label: '东川区' }, + { value: '4311', label: '官渡区' }, + { value: '4312', label: '盘龙区' }, + { value: '4313', label: '五华区' }, + { value: '4314', label: '西山区' }, + { value: '530129', label: '寻甸回族彝族自治县' }, + { value: '530182', label: '其它区' }, + ], + }, + { + value: '3571', + label: '曲靖', + children: [ + { value: '3572', label: '曲靖市' }, + { value: '3573', label: '宣威市' }, + { value: '3574', label: '陆良县' }, + { value: '3575', label: '会泽县' }, + { value: '3576', label: '富源县' }, + { value: '3577', label: '罗平县' }, + { value: '3578', label: '马龙县' }, + { value: '3579', label: '师宗县' }, + { value: '3580', label: '沾益县' }, + { value: '4316', label: '麒麟区' }, + { value: '530382', label: '其它区' }, + ], + }, + { + value: '3581', + label: '玉溪', + children: [ + { value: '3582', label: '玉溪市' }, + { value: '3583', label: '华宁县' }, + { value: '3584', label: '澄江县' }, + { value: '3585', label: '易门县' }, + { value: '3586', label: '通海县' }, + { value: '3587', label: '江川县' }, + { value: '3588', label: '元江哈尼族彝族傣族自治县' }, + { value: '3589', label: '新平彝族傣族自治县' }, + { value: '3590', label: '峨山彝族自治县' }, + { value: '4317', label: '红塔区' }, + { value: '530429', label: '其它区' }, + ], + }, + { + value: '3591', + label: '保山', + children: [ + { value: '3592', label: '保山市' }, + { value: '3593', label: '施甸县' }, + { value: '3595', label: '龙陵县' }, + { value: '3596', label: '腾冲县' }, + { value: '3594', label: '昌宁县' }, + { value: '4309', label: '隆阳区' }, + { value: '530525', label: '其它区' }, + ], + }, + { + value: '3597', + label: '昭通', + children: [ + { value: '3598', label: '昭通市' }, + { value: '3599', label: '永善县' }, + { value: '3600', label: '绥江县' }, + { value: '3601', label: '镇雄县' }, + { value: '3602', label: '大关县' }, + { value: '3603', label: '盐津县' }, + { value: '3604', label: '巧家县' }, + { value: '3605', label: '彝良县' }, + { value: '3607', label: '水富县' }, + { value: '3608', label: '鲁甸县' }, + { value: '3606', label: '威信县' }, + { value: '4318', label: '昭阳区' }, + { value: '530631', label: '其它区' }, + ], + }, + { + value: '3609', + label: '普洱', + children: [ + { value: '3610', label: '普洱市' }, + { value: '3611', label: '宁洱哈尼族彝族自治县' }, + { value: '3612', label: '景东彝族自治县' }, + { value: '3613', label: '镇沅彝族哈尼族拉祜族自治县' }, + { value: '3614', label: '景谷傣族彝族自治县' }, + { value: '3615', label: '墨江哈尼族自治县' }, + { value: '3616', label: '澜沧拉祜族自治县' }, + { value: '3617', label: '西盟佤族自治县' }, + { value: '3618', label: '江城哈尼族彝族自治县' }, + { value: '3619', label: '孟连傣族拉祜族佤族自治县' }, + { value: '4845', label: '思茅区' }, + { value: '530830', label: '其它区' }, + ], + }, + { + value: '3620', + label: '临沧', + children: [ + { value: '3622', label: '镇康县' }, + { value: '3623', label: '凤庆县' }, + { value: '3624', label: '云县' }, + { value: '3625', label: '永德县' }, + { value: '3626', label: '双江拉祜族佤族布朗族傣族自治县' }, + { value: '3627', label: '沧源佤族自治县' }, + { value: '3628', label: '耿马傣族佤族自治县' }, + { value: '3783', label: '临沧市' }, + { value: '3621', label: '临翔区' }, + { value: '530928', label: '其它区' }, + ], + }, + { + value: '3629', + label: '丽江', + children: [ + { value: '3630', label: '丽江市' }, + { value: '3631', label: '玉龙纳西族自治县' }, + { value: '3632', label: '华坪县' }, + { value: '3633', label: '永胜县' }, + { value: '3634', label: '宁蒗彝族自治县' }, + { value: '4315', label: '古城区' }, + { value: '530725', label: '其它区' }, + ], + }, + { + value: '3635', + label: '文山壮族苗族自治州', + children: [ + { value: '3636', label: '文山县' }, + { value: '3637', label: '麻栗坡县' }, + { value: '3638', label: '砚山县' }, + { value: '3639', label: '广南县' }, + { value: '3640', label: '马关县' }, + { value: '3641', label: '富宁县' }, + { value: '3642', label: '西畴县' }, + { value: '3643', label: '丘北县' }, + ], + }, + { + value: '3644', + label: '红河哈尼族彝族自治州', + children: [ + { value: '3645', label: '个旧市' }, + { value: '3646', label: '开远市' }, + { value: '3647', label: '弥勒县' }, + { value: '3648', label: '红河县' }, + { value: '3649', label: '绿春县' }, + { value: '3650', label: '蒙自县' }, + { value: '3651', label: '泸西县' }, + { value: '3652', label: '建水县' }, + { value: '3653', label: '元阳县' }, + { value: '3654', label: '石屏县' }, + { value: '3656', label: '河口瑶族自治县' }, + { value: '3657', label: '屏边苗族自治县' }, + { value: '3655', label: '金平苗族瑶族傣族自治县' }, + ], + }, + { + value: '3658', + label: '西双版纳傣族自治州', + children: [ + { value: '3659', label: '景洪市' }, + { value: '3660', label: '勐海县' }, + { value: '3661', label: '勐腊县' }, + ], + }, + { + value: '3662', + label: '楚雄彝族自治州', + children: [ + { value: '3663', label: '楚雄市' }, + { value: '3664', label: '元谋县' }, + { value: '3665', label: '南华县' }, + { value: '3666', label: '牟定县' }, + { value: '3667', label: '武定县' }, + { value: '3668', label: '大姚县' }, + { value: '3669', label: '双柏县' }, + { value: '3670', label: '禄丰县' }, + { value: '3671', label: '永仁县' }, + { value: '3672', label: '姚安县' }, + ], + }, + { + value: '3673', + label: '大理白族自治州', + children: [ + { value: '3674', label: '大理市' }, + { value: '3675', label: '剑川县' }, + { value: '3676', label: '弥渡县' }, + { value: '3677', label: '云龙县' }, + { value: '3678', label: '洱源县' }, + { value: '3679', label: '鹤庆县' }, + { value: '3680', label: '祥云县' }, + { value: '3681', label: '宾川县' }, + { value: '3682', label: '永平县' }, + { value: '3683', label: '漾濞彝族自治县' }, + { value: '3684', label: '巍山彝族回族自治县' }, + { value: '3685', label: '南涧彝族自治县' }, + ], + }, + { + value: '3686', + label: '德宏傣族景颇族自治州', + children: [ + { value: '3687', label: '潞西市' }, + { value: '3688', label: '瑞丽市' }, + { value: '3689', label: '盈江县' }, + { value: '3690', label: '梁河县' }, + { value: '3691', label: '陇川县' }, + ], + }, + { + value: '3692', + label: '怒江傈傈族自治州', + children: [ + { value: '3693', label: '泸水县' }, + { value: '3694', label: '福贡县' }, + { value: '3695', label: '兰坪白族普米族自治县' }, + { value: '3696', label: '贡山独龙族怒族自治县' }, + ], + }, + { + value: '3697', + label: '迪庆藏族自治州', + children: [ + { value: '3698', label: '香格里拉县' }, + { value: '3699', label: '德钦县' }, + { value: '3700', label: '维西傈僳族自治县' }, + ], + }, + ], + value: '3559', + label: '云南', + }, + { + children: [ + { + value: '1909', + label: '武汉', + children: [ + { value: '1910', label: '武汉市' }, + { value: '4423', label: '江岸区' }, + { value: '4769', label: '蔡甸区' }, + { value: '4770', label: '东西湖区' }, + { value: '4771', label: '汉南区' }, + { value: '4772', label: '汉阳区' }, + { value: '4773', label: '洪山区' }, + { value: '4774', label: '黄陂区' }, + { value: '4775', label: '江汉区' }, + { value: '4776', label: '江夏区' }, + { value: '4777', label: '硚口区' }, + { value: '4778', label: '青山区' }, + { value: '4779', label: '武昌区' }, + { value: '4780', label: '新洲区' }, + { value: '420118', label: '其它区' }, + ], + }, + { + value: '1911', + label: '黄石', + children: [ + { value: '1912', label: '黄石市' }, + { value: '1913', label: '大冶市' }, + { value: '1914', label: '阳新县' }, + { value: '4421', label: '铁山区' }, + { value: '4759', label: '黄石港区' }, + { value: '4760', label: '西塞山区' }, + { value: '4761', label: '下陆区' }, + { value: '420282', label: '其它区' }, + ], + }, + { + value: '1915', + label: '襄樊', + children: [ + { value: '1920', label: '南漳县' }, + { value: '1916', label: '襄樊市' }, + { value: '1917', label: '老河口市' }, + { value: '1918', label: '枣阳市' }, + { value: '1919', label: '宜城市' }, + { value: '1921', label: '谷城县' }, + { value: '1922', label: '保康县' }, + { value: '4424', label: '樊城区' }, + { value: '4782', label: '襄城区' }, + { value: '4783', label: '襄阳区' }, + ], + }, + { + value: '1923', + label: '十堰', + children: [ + { value: '1924', label: '十堰市' }, + { value: '1925', label: '丹江口市' }, + { value: '1926', label: '郧县' }, + { value: '1927', label: '竹山县' }, + { value: '1928', label: '房县' }, + { value: '1929', label: '郧西县' }, + { value: '1930', label: '竹溪县' }, + { value: '4422', label: '张湾区' }, + { value: '4767', label: '茅箭区' }, + { value: '420383', label: '其它区' }, + ], + }, + { + value: '1931', + label: '荆州', + children: [ + { value: '1933', label: '洪湖市' }, + { value: '1932', label: '荆州市' }, + { value: '1934', label: '石首市' }, + { value: '1935', label: '松滋市' }, + { value: '1936', label: '监利县' }, + { value: '1937', label: '公安县' }, + { value: '1938', label: '江陵县' }, + { value: '4764', label: '荆州区' }, + { value: '4765', label: '沙市区' }, + { value: '421088', label: '其它区' }, + ], + }, + { + value: '1939', + label: '宜昌', + children: [ + { value: '1940', label: '宜昌市' }, + { value: '1941', label: '宜都市' }, + { value: '1942', label: '当阳市' }, + { value: '1943', label: '枝江市' }, + { value: '1944', label: '秭归县' }, + { value: '1945', label: '远安县' }, + { value: '1946', label: '兴山县' }, + { value: '1947', label: '五峰土家族自治县' }, + { value: '1948', label: '长阳土家族自治县' }, + { value: '4785', label: '点军区' }, + { value: '4786', label: '伍家岗区' }, + { value: '4787', label: '西陵区' }, + { value: '4788', label: '猇亭区' }, + { value: '4789', label: '夷陵区' }, + { value: '420551', label: '葛洲坝区' }, + { value: '420584', label: '其它区' }, + ], + }, + { + value: '1949', + label: '荆门', + children: [ + { value: '1950', label: '荆门市' }, + { value: '1951', label: '钟祥市' }, + { value: '1952', label: '京山县' }, + { value: '1953', label: '沙洋县' }, + { value: '4762', label: '东宝区' }, + { value: '4763', label: '掇刀区' }, + { value: '420882', label: '其它区' }, + ], + }, + { + value: '1954', + label: '鄂州', + children: [ + { value: '1955', label: '鄂州市' }, + { value: '4755', label: '鄂城区' }, + { value: '4756', label: '华容区' }, + { value: '4757', label: '梁子湖区' }, + { value: '420705', label: '其它区' }, + ], + }, + { + value: '1956', + label: '孝感', + children: [ + { value: '1957', label: '孝感市' }, + { value: '1958', label: '应城市' }, + { value: '1959', label: '安陆市' }, + { value: '1960', label: '汉川市' }, + { value: '1961', label: '云梦县' }, + { value: '1962', label: '大悟县' }, + { value: '1963', label: '孝昌县' }, + { value: '4784', label: '孝南区' }, + { value: '420985', label: '其它区' }, + ], + }, + { + value: '1964', + label: '黄冈', + children: [ + { value: '1965', label: '黄冈市' }, + { value: '1966', label: '麻城市' }, + { value: '1967', label: '武穴市' }, + { value: '1968', label: '红安县' }, + { value: '1969', label: '罗田县' }, + { value: '1970', label: '浠水县' }, + { value: '1971', label: '蕲春县' }, + { value: '1972', label: '黄梅县' }, + { value: '1973', label: '英山县' }, + { value: '1974', label: '团风县' }, + { value: '4758', label: '黄州区' }, + { value: '421183', label: '其它区' }, + ], + }, + { + value: '1975', + label: '咸宁', + children: [ + { value: '1976', label: '咸宁市' }, + { value: '1977', label: '赤壁市' }, + { value: '1978', label: '嘉鱼县' }, + { value: '1979', label: '通山县' }, + { value: '1980', label: '崇阳县' }, + { value: '1981', label: '通城县' }, + { value: '4781', label: '咸安区' }, + { value: '421282', label: '温泉城区' }, + { value: '421283', label: '其它区' }, + ], + }, + { + value: '1982', + label: '随州', + children: [ + { value: '1983', label: '随州市' }, + { value: '1984', label: '广水市' }, + { value: '4768', label: '曾都区' }, + { value: '421321', label: '随县' }, + { value: '421382', label: '其它区' }, + ], + }, + { + value: '1985', + label: '仙桃', + children: [{ value: '1986', label: '仙桃市' }], + }, + { + value: '1987', + label: '天门', + children: [{ value: '1988', label: '天门市' }], + }, + { + value: '1989', + label: '潜江', + children: [{ value: '1990', label: '潜江市' }], + }, + { + value: '1991', + label: '神农架林区', + children: [ + { value: '1992', label: '神农架林区' }, + { value: '4766', label: '神农架林区' }, + ], + }, + { + value: '1993', + label: '恩施土家族苗族自治州', + children: [ + { value: '1996', label: '建始县' }, + { value: '1994', label: '恩施市' }, + { value: '1995', label: '利川市' }, + { value: '1997', label: '来凤县' }, + { value: '1998', label: '巴东县' }, + { value: '1999', label: '鹤峰县' }, + { value: '2000', label: '宣恩县' }, + { value: '2001', label: '咸丰县' }, + ], + }, + ], + value: '1908', + label: '湖北', + }, + { + children: [ + { + value: '2259', + label: '南昌', + children: [ + { value: '2260', label: '南昌市' }, + { value: '2261', label: '新建县' }, + { value: '2262', label: '南昌县' }, + { value: '2263', label: '进贤县' }, + { value: '2264', label: '安义县' }, + { value: '4047', label: '东湖区' }, + { value: '4048', label: '青山湖区' }, + { value: '4049', label: '青云谱区' }, + { value: '4050', label: '湾里区' }, + { value: '4051', label: '西湖区' }, + { value: '360125', label: '红谷滩新区' }, + { value: '360127', label: '昌北区' }, + { value: '360128', label: '其它区' }, + ], + }, + { + value: '2265', + label: '景德镇', + children: [ + { value: '2266', label: '景德镇市' }, + { value: '2267', label: '乐平市' }, + { value: '2268', label: '浮梁县' }, + { value: '4044', label: '昌江区' }, + { value: '4045', label: '珠山区' }, + { value: '360282', label: '其它区' }, + ], + }, + { + value: '2269', + label: '萍乡', + children: [ + { value: '2271', label: '莲花县' }, + { value: '2270', label: '萍乡市' }, + { value: '2272', label: '上栗县' }, + { value: '2273', label: '芦溪县' }, + { value: '4052', label: '安源区' }, + { value: '4369', label: '湘东区' }, + { value: '360324', label: '其它区' }, + ], + }, + { + value: '2274', + label: '新余', + children: [ + { value: '2275', label: '新余市' }, + { value: '2276', label: '分宜县' }, + { value: '4054', label: '渝水区' }, + { value: '360522', label: '其它区' }, + ], + }, + { + value: '2277', + label: '九江', + children: [ + { value: '2278', label: '九江市' }, + { value: '2279', label: '瑞昌市' }, + { value: '2280', label: '九江县' }, + { value: '2281', label: '星子县' }, + { value: '2282', label: '武宁县' }, + { value: '2283', label: '彭泽县' }, + { value: '2284', label: '永修县' }, + { value: '2285', label: '修水县' }, + { value: '2286', label: '湖口县' }, + { value: '2287', label: '德安县' }, + { value: '2288', label: '都昌县' }, + { value: '4046', label: '浔阳区' }, + { value: '4368', label: '庐山区' }, + { value: '360482', label: '其它区' }, + ], + }, + { + value: '2289', + label: '鹰潭', + children: [ + { value: '2290', label: '鹰潭市' }, + { value: '2291', label: '贵溪市' }, + { value: '2292', label: '余江县' }, + { value: '4056', label: '月湖区' }, + { value: '360682', label: '其它区' }, + ], + }, + { + value: '2293', + label: '赣州', + children: [ + { value: '2294', label: '赣州市' }, + { value: '2295', label: '瑞金市' }, + { value: '2296', label: '南康市' }, + { value: '2297', label: '石城县' }, + { value: '2298', label: '安远县' }, + { value: '2299', label: '赣县' }, + { value: '2300', label: '宁都县' }, + { value: '2301', label: '寻乌县' }, + { value: '2302', label: '兴国县' }, + { value: '2303', label: '定南县' }, + { value: '2304', label: '上犹县' }, + { value: '2305', label: '于都县' }, + { value: '2306', label: '龙南县' }, + { value: '2307', label: '崇义县' }, + { value: '2308', label: '信丰县' }, + { value: '2309', label: '全南县' }, + { value: '2310', label: '大余县' }, + { value: '2311', label: '会昌县' }, + { value: '4041', label: '章贡区' }, + { value: '360751', label: '黄金区' }, + { value: '360783', label: '其它区' }, + ], + }, + { + value: '2312', + label: '吉安', + children: [ + { value: '2314', label: '井冈山市' }, + { value: '2313', label: '吉安市' }, + { value: '2315', label: '吉安县' }, + { value: '2316', label: '永丰县' }, + { value: '2317', label: '永新县' }, + { value: '2318', label: '新干县' }, + { value: '2319', label: '泰和县' }, + { value: '2320', label: '峡江县' }, + { value: '2321', label: '遂川县' }, + { value: '2322', label: '安福县' }, + { value: '2323', label: '吉水县' }, + { value: '2324', label: '万安县' }, + { value: '4042', label: '吉州区' }, + { value: '4043', label: '青原区' }, + { value: '360882', label: '其它区' }, + ], + }, + { + value: '2325', + label: '宜春', + children: [ + { value: '2327', label: '丰城市' }, + { value: '2326', label: '宜春市' }, + { value: '2328', label: '樟树市' }, + { value: '2329', label: '高安市' }, + { value: '2330', label: '铜鼓县' }, + { value: '2331', label: '靖安县' }, + { value: '2332', label: '宜丰县' }, + { value: '2333', label: '奉新县' }, + { value: '2334', label: '万载县' }, + { value: '2335', label: '上高县' }, + { value: '4055', label: '袁州区' }, + { value: '360984', label: '其它区' }, + ], + }, + { + value: '2336', + label: '抚州', + children: [ + { value: '2340', label: '金溪县' }, + { value: '2337', label: '抚州市' }, + { value: '2338', label: '南丰县' }, + { value: '2339', label: '乐安县' }, + { value: '2341', label: '南城县' }, + { value: '2342', label: '东乡县' }, + { value: '2343', label: '资溪县' }, + { value: '2344', label: '宜黄县' }, + { value: '2345', label: '广昌县' }, + { value: '2346', label: '黎川县' }, + { value: '2347', label: '崇仁县' }, + { value: '4040', label: '临川区' }, + { value: '361031', label: '其它区' }, + ], + }, + { + value: '2348', + label: '上饶', + children: [ + { value: '2349', label: '上饶市' }, + { value: '2350', label: '德兴市' }, + { value: '2351', label: '上饶县' }, + { value: '2352', label: '广丰县' }, + { value: '2353', label: '鄱阳县' }, + { value: '2354', label: '婺源县' }, + { value: '2355', label: '铅山县' }, + { value: '2356', label: '余干县' }, + { value: '2357', label: '横峰县' }, + { value: '2358', label: '弋阳县' }, + { value: '2359', label: '玉山县' }, + { value: '2360', label: '万年县' }, + { value: '4053', label: '信州区' }, + { value: '361182', label: '其它区' }, + ], + }, + ], + value: '2258', + label: '江西', + }, + { + children: [ + { + value: '3263', + label: '重庆', + children: [ + { value: '3264', label: '重庆市' }, + { value: '3269', label: '綦江县' }, + { value: '3270', label: '潼南县' }, + { value: '3271', label: '荣昌县' }, + { value: '3272', label: '璧山县' }, + { value: '3273', label: '大足县' }, + { value: '3275', label: '梁平县' }, + { value: '3276', label: '城口县' }, + { value: '3277', label: '垫江县' }, + { value: '3278', label: '武隆县' }, + { value: '3279', label: '丰都县' }, + { value: '3280', label: '奉节县' }, + { value: '3281', label: '开县' }, + { value: '3282', label: '云阳县' }, + { value: '3283', label: '忠县' }, + { value: '3284', label: '巫溪县' }, + { value: '3285', label: '巫山县' }, + { value: '3286', label: '石柱土家族自治县' }, + { value: '3287', label: '秀山土家族苗族自治县' }, + { value: '3288', label: '酉阳土家族苗族自治县' }, + { value: '3289', label: '彭水苗族土家族自治县' }, + { value: '3274', label: '铜梁县' }, + { value: '3265', label: '永川区' }, + { value: '3266', label: '合川区' }, + { value: '3267', label: '江津区' }, + { value: '3268', label: '南川区' }, + { value: '4351', label: '巴南区' }, + { value: '4352', label: '北碚区' }, + { value: '4353', label: '长寿区' }, + { value: '4354', label: '大渡口区' }, + { value: '4355', label: '涪陵区' }, + { value: '4356', label: '江北区' }, + { value: '4357', label: '九龙坡区' }, + { value: '4358', label: '南岸区' }, + { value: '4359', label: '黔江区' }, + { value: '4360', label: '沙坪坝区' }, + { value: '4361', label: '双桥区' }, + { value: '4362', label: '万盛区' }, + { value: '4363', label: '万州区' }, + { value: '4364', label: '渝北区' }, + { value: '4365', label: '渝中区' }, + { value: '10002', label: '高新区' }, + { value: '10003', label: '北部新区' }, + { value: '10004', label: '经济技术开发区' }, + { value: '500385', label: '其它区' }, + ], + }, + ], + value: '3262', + label: '重庆', + }, + { + children: [ + { + value: '2537', + label: '银川', + children: [ + { value: '2538', label: '银川市' }, + { value: '2539', label: '永宁县' }, + { value: '2540', label: '贺兰县' }, + { value: '2541', label: '灵武市' }, + { value: '4128', label: '金凤区' }, + { value: '4129', label: '西夏区' }, + { value: '4130', label: '兴庆区' }, + { value: '640182', label: '其它区' }, + ], + }, + { + value: '2542', + label: '石嘴山', + children: [ + { value: '2543', label: '石嘴山市' }, + { value: '2544', label: '平罗县' }, + { value: '2546', label: '惠农区' }, + { value: '4126', label: '大武口区' }, + { value: '640222', label: '其它区' }, + ], + }, + { + value: '2547', + label: '吴忠', + children: [ + { value: '2548', label: '吴忠市' }, + { value: '2549', label: '青铜峡市' }, + { value: '2550', label: '同心县' }, + { value: '2551', label: '盐池县' }, + { value: '4127', label: '利通区' }, + { value: '640303', label: '红寺堡区' }, + { value: '640382', label: '其它区' }, + ], + }, + { + value: '2552', + label: '中卫', + children: [ + { value: '3702', label: '中卫市' }, + { value: '2553', label: '中宁县' }, + { value: '2556', label: '海原县' }, + { value: '4131', label: '沙坡头区' }, + { value: '640523', label: '其它区' }, + ], + }, + { + value: '2554', + label: '固原', + children: [ + { value: '2555', label: '固原市' }, + { value: '2557', label: '西吉县' }, + { value: '2558', label: '隆德县' }, + { value: '2559', label: '泾源县' }, + { value: '2560', label: '彭阳县' }, + { value: '4125', label: '原州区' }, + { value: '640426', label: '其它区' }, + ], + }, + ], + value: '2536', + label: '宁夏', + }, + { + children: [ + { + value: '2562', + label: '西宁', + children: [ + { value: '2563', label: '西宁市' }, + { value: '2564', label: '湟源县' }, + { value: '2565', label: '湟中县' }, + { value: '2566', label: '大通回族土族自治县' }, + { value: '4132', label: '城北区' }, + { value: '4133', label: '城东区' }, + { value: '4134', label: '城西区' }, + { value: '4378', label: '城中区' }, + { value: '630124', label: '其它区' }, + ], + }, + { + value: '2567', + label: '海东', + children: [ + { value: '2568', label: '平安县' }, + { value: '2569', label: '乐都县' }, + { value: '2570', label: '民和回族土族自治县' }, + { value: '2571', label: '互助土族自治县' }, + { value: '2572', label: '化隆回族自治县' }, + { value: '2573', label: '循化撒拉族自治县' }, + ], + }, + { + value: '2574', + label: '海北藏族自治州', + children: [ + { value: '2575', label: '海晏县' }, + { value: '2576', label: '祁连县' }, + { value: '2577', label: '刚察县' }, + { value: '2578', label: '门源回族自治县' }, + ], + }, + { + value: '2579', + label: '黄南藏族自治州', + children: [ + { value: '2580', label: '同仁县' }, + { value: '2581', label: '泽库县' }, + { value: '2582', label: '尖扎县' }, + { value: '2583', label: '河南蒙古族自治县' }, + ], + }, + { + value: '2584', + label: '海南藏族自治州', + children: [ + { value: '2587', label: '贵德县' }, + { value: '2585', label: '共和县' }, + { value: '2586', label: '同德县' }, + { value: '2588', label: '兴海县' }, + { value: '2589', label: '贵南县' }, + ], + }, + { + value: '2590', + label: '果洛藏族自治州', + children: [ + { value: '2591', label: '玛沁县' }, + { value: '2592', label: '班玛县' }, + { value: '2593', label: '甘德县' }, + { value: '2594', label: '达日县' }, + { value: '2595', label: '久治县' }, + { value: '2596', label: '玛多县' }, + ], + }, + { + value: '2597', + label: '玉树藏族自治州', + children: [ + { value: '2599', label: '杂多县' }, + { value: '2598', label: '玉树县' }, + { value: '2600', label: '称多县' }, + { value: '2601', label: '治多县' }, + { value: '2602', label: '囊谦县' }, + { value: '2603', label: '曲麻莱县' }, + ], + }, + { + value: '2604', + label: '海西蒙古族藏族自治州', + children: [ + { value: '2605', label: '德令哈市' }, + { value: '2606', label: '格尔木市' }, + { value: '2607', label: '乌兰县' }, + { value: '2608', label: '天峻县' }, + { value: '2609', label: '都兰县' }, + ], + }, + ], + value: '2561', + label: '青海', + }, + { + children: [ + { + value: '2611', + label: '上海', + children: [ + { value: '2612', label: '上海市' }, + { value: '2613', label: '崇明县' }, + { value: '4221', label: '宝山区' }, + { value: '4222', label: '长宁区' }, + { value: '4223', label: '奉贤区' }, + { value: '4224', label: '虹口区' }, + { value: '4225', label: '黄浦区' }, + { value: '4226', label: '嘉定区' }, + { value: '4227', label: '金山区' }, + { value: '4228', label: '静安区' }, + { value: '4229', label: '卢湾区' }, + { value: '4230', label: '闵行区' }, + { value: '4231', label: '南汇区' }, + { value: '4232', label: '浦东新区' }, + { value: '4233', label: '普陀区' }, + { value: '4234', label: '青浦区' }, + { value: '4235', label: '松江区' }, + { value: '4236', label: '徐汇区' }, + { value: '4237', label: '杨浦区' }, + { value: '4238', label: '闸北区' }, + { value: '310152', label: '川沙区' }, + { value: '310231', label: '其它区' }, + ], + }, + ], + value: '2610', + label: '上海', + }, + { + children: [ + { + value: '2615', + label: '广州', + children: [ + { value: '2616', label: '广州市' }, + { value: '2617', label: '从化市' }, + { value: '2618', label: '增城市' }, + { value: '4398', label: '海珠区' }, + { value: '4532', label: '白云区' }, + { value: '4533', label: '番禺区' }, + { value: '4534', label: '花都区' }, + { value: '4535', label: '黄埔区' }, + { value: '4536', label: '荔湾区' }, + { value: '4537', label: '萝岗区' }, + { value: '4538', label: '南沙区' }, + { value: '4539', label: '天河区' }, + { value: '4540', label: '越秀区' }, + { value: '440189', label: '其它区' }, + ], + }, + { + value: '2619', + label: '深圳', + children: [ + { value: '2620', label: '深圳市' }, + { value: '4402', label: '南山区' }, + { value: '4558', label: '宝安区' }, + { value: '4559', label: '福田区' }, + { value: '4560', label: '龙岗区' }, + { value: '4561', label: '罗湖区' }, + { value: '4562', label: '盐田区' }, + { value: '440309', label: '其它区' }, + ], + }, + { + value: '2621', + label: '珠海', + children: [ + { value: '2622', label: '珠海市' }, + { value: '4570', label: '斗门区' }, + { value: '4571', label: '金湾区' }, + { value: '4572', label: '香洲区' }, + { value: '440486', label: '金唐区' }, + { value: '440487', label: '南湾区' }, + { value: '440488', label: '其它区' }, + ], + }, + { + value: '2623', + label: '汕头', + children: [ + { value: '2624', label: '汕头市' }, + { value: '2627', label: '南澳县' }, + { value: '4550', label: '潮南区' }, + { value: '4551', label: '潮阳区' }, + { value: '4552', label: '澄海区' }, + { value: '4553', label: '濠江区' }, + { value: '4554', label: '金平区' }, + { value: '4555', label: '龙湖区' }, + { value: '440524', label: '其它区' }, + ], + }, + { + value: '2628', + label: '韶关', + children: [ + { value: '2629', label: '韶关市' }, + { value: '2630', label: '乐昌市' }, + { value: '2631', label: '南雄市' }, + { value: '2632', label: '仁化县' }, + { value: '2633', label: '始兴县' }, + { value: '2634', label: '翁源县' }, + { value: '2636', label: '新丰县' }, + { value: '2637', label: '乳源瑶族自治县' }, + { value: '2635', label: '曲江区' }, + { value: '4556', label: '武江区' }, + { value: '4557', label: '浈江区' }, + { value: '440283', label: '其它区' }, + ], + }, + { + value: '2638', + label: '河源', + children: [ + { value: '2643', label: '连平县' }, + { value: '2639', label: '河源市' }, + { value: '2640', label: '和平县' }, + { value: '2641', label: '龙川县' }, + { value: '2642', label: '紫金县' }, + { value: '2644', label: '东源县' }, + { value: '4541', label: '源城区' }, + { value: '441626', label: '其它区' }, + ], + }, + { + value: '2645', + label: '梅州', + children: [ + { value: '2646', label: '梅州市' }, + { value: '2647', label: '兴宁市' }, + { value: '2648', label: '梅县' }, + { value: '2649', label: '蕉岭县' }, + { value: '2650', label: '大埔县' }, + { value: '2651', label: '丰顺县' }, + { value: '2652', label: '五华县' }, + { value: '2653', label: '平远县' }, + { value: '4400', label: '梅江区' }, + { value: '441482', label: '其它区' }, + ], + }, + { + value: '2654', + label: '惠州', + children: [ + { value: '2655', label: '惠州市' }, + { value: '2657', label: '惠东县' }, + { value: '2658', label: '博罗县' }, + { value: '2659', label: '龙门县' }, + { value: '4399', label: '惠城区' }, + { value: '4542', label: '惠阳区' }, + { value: '441325', label: '其它区' }, + ], + }, + { + value: '2660', + label: '汕尾', + children: [ + { value: '2661', label: '汕尾市' }, + { value: '2662', label: '陆丰市' }, + { value: '2663', label: '海丰县' }, + { value: '2664', label: '陆河县' }, + { value: '4401', label: '城区' }, + { value: '441582', label: '其它区' }, + ], + }, + { + value: '2665', + label: '东莞', + children: [{ value: '2666', label: '东莞市' }], + }, + { + value: '2667', + label: '中山', + children: [{ value: '2668', label: '中山市' }], + }, + { + value: '2669', + label: '江门', + children: [ + { value: '2670', label: '江门市' }, + { value: '2671', label: '台山市' }, + { value: '2672', label: '开平市' }, + { value: '2673', label: '鹤山市' }, + { value: '2674', label: '恩平市' }, + { value: '4543', label: '江海区' }, + { value: '4544', label: '蓬江区' }, + { value: '4545', label: '新会区' }, + { value: '440786', label: '其它区' }, + ], + }, + { + value: '2675', + label: '佛山', + children: [ + { value: '2676', label: '佛山市' }, + { value: '4527', label: '禅城区' }, + { value: '4528', label: '高明区' }, + { value: '4529', label: '南海区' }, + { value: '4530', label: '三水区' }, + { value: '4531', label: '顺德区' }, + { value: '440609', label: '其它区' }, + ], + }, + { + value: '2677', + label: '阳江', + children: [ + { value: '2678', label: '阳江市' }, + { value: '2679', label: '阳春市' }, + { value: '2680', label: '阳西县' }, + { value: '2681', label: '阳东县' }, + { value: '4563', label: '江城区' }, + { value: '441782', label: '其它区' }, + ], + }, + { + value: '2682', + label: '湛江', + children: [ + { value: '2683', label: '湛江市' }, + { value: '2684', label: '廉江市' }, + { value: '2685', label: '雷州市' }, + { value: '2686', label: '吴川市' }, + { value: '2687', label: '遂溪县' }, + { value: '2688', label: '徐闻县' }, + { value: '4565', label: '赤坎区' }, + { value: '4566', label: '麻章区' }, + { value: '4567', label: '坡头区' }, + { value: '4568', label: '霞山区' }, + { value: '440884', label: '其它区' }, + ], + }, + { + value: '2689', + label: '茂名', + children: [ + { value: '2690', label: '茂名市' }, + { value: '2691', label: '高州市' }, + { value: '2692', label: '化州市' }, + { value: '2693', label: '信宜市' }, + { value: '2694', label: '电白县' }, + { value: '4547', label: '茂港区' }, + { value: '4548', label: '茂南区' }, + { value: '440984', label: '其它区' }, + ], + }, + { + value: '2695', + label: '肇庆', + children: [ + { value: '2696', label: '肇庆市' }, + { value: '2697', label: '高要市' }, + { value: '2698', label: '四会市' }, + { value: '2699', label: '广宁县' }, + { value: '2700', label: '德庆县' }, + { value: '2701', label: '封开县' }, + { value: '2702', label: '怀集县' }, + { value: '4403', label: '鼎湖区' }, + { value: '4569', label: '端州区' }, + { value: '441285', label: '其它区' }, + ], + }, + { + value: '2703', + label: '清远', + children: [ + { value: '2704', label: '清远市' }, + { value: '2705', label: '英德市' }, + { value: '2706', label: '连州市' }, + { value: '2707', label: '佛冈县' }, + { value: '2708', label: '阳山县' }, + { value: '2709', label: '清新县' }, + { value: '2710', label: '连山壮族瑶族自治县' }, + { value: '2711', label: '连南瑶族自治县' }, + { value: '4549', label: '清城区' }, + { value: '441883', label: '其它区' }, + ], + }, + { + value: '2712', + label: '潮州', + children: [ + { value: '2713', label: '潮州市' }, + { value: '2714', label: '潮安县' }, + { value: '2715', label: '饶平县' }, + { value: '4397', label: '湘桥区' }, + { value: '445185', label: '枫溪区' }, + { value: '445186', label: '其它区' }, + ], + }, + { + value: '2716', + label: '揭阳', + children: [ + { value: '2717', label: '揭阳市' }, + { value: '2718', label: '普宁市' }, + { value: '2719', label: '揭东县' }, + { value: '2720', label: '揭西县' }, + { value: '2721', label: '惠来县' }, + { value: '4546', label: '榕城区' }, + { value: '445285', label: '其它区' }, + ], + }, + { + value: '2722', + label: '云浮', + children: [ + { value: '2723', label: '云浮市' }, + { value: '2724', label: '罗定市' }, + { value: '2725', label: '云安县' }, + { value: '2726', label: '新兴县' }, + { value: '2727', label: '郁南县' }, + { value: '4564', label: '云城区' }, + { value: '445382', label: '其它区' }, + ], + }, + ], + value: '2614', + label: '广东', + }, + { + children: [ + { + value: '2729', + label: '太原', + children: [ + { value: '2734', label: '娄烦县' }, + { value: '2730', label: '太原市' }, + { value: '2731', label: '古交市' }, + { value: '2732', label: '阳曲县' }, + { value: '2733', label: '清徐县' }, + { value: '4189', label: '尖草坪区' }, + { value: '4190', label: '晋源区' }, + { value: '4191', label: '万柏林区' }, + { value: '4192', label: '小店区' }, + { value: '4193', label: '杏花岭区' }, + { value: '4386', label: '迎泽区' }, + { value: '140182', label: '其它区' }, + ], + }, + { + value: '2735', + label: '大同', + children: [ + { value: '2736', label: '大同市' }, + { value: '2737', label: '大同县' }, + { value: '2738', label: '天镇县' }, + { value: '2739', label: '灵丘县' }, + { value: '2740', label: '阳高县' }, + { value: '2741', label: '左云县' }, + { value: '2742', label: '广灵县' }, + { value: '2743', label: '浑源县' }, + { value: '4180', label: '城区' }, + { value: '4181', label: '矿区' }, + { value: '4182', label: '南郊区' }, + { value: '4183', label: '新荣区' }, + { value: '140228', label: '其它区' }, + ], + }, + { + value: '2744', + label: '阳泉', + children: [ + { value: '2745', label: '阳泉市' }, + { value: '2746', label: '平定县' }, + { value: '2747', label: '盂县' }, + { value: '4195', label: '城区' }, + { value: '4196', label: '郊区' }, + { value: '4197', label: '矿区' }, + { value: '140323', label: '其它区' }, + ], + }, + { + value: '2748', + label: '长治', + children: [ + { value: '2749', label: '长治市' }, + { value: '2750', label: '潞城市' }, + { value: '2751', label: '长治县' }, + { value: '2752', label: '长子县' }, + { value: '2753', label: '平顺县' }, + { value: '2754', label: '襄垣县' }, + { value: '2755', label: '沁源县' }, + { value: '2756', label: '屯留县' }, + { value: '2757', label: '黎城县' }, + { value: '2758', label: '武乡县' }, + { value: '2759', label: '沁县' }, + { value: '2760', label: '壶关县' }, + { value: '4179', label: '城区' }, + { value: '4384', label: '郊区' }, + { value: '140485', label: '其它区' }, + ], + }, + { + value: '2761', + label: '晋城', + children: [ + { value: '2762', label: '晋城市' }, + { value: '2763', label: '高平市' }, + { value: '2764', label: '泽州县' }, + { value: '2765', label: '陵川县' }, + { value: '2766', label: '阳城县' }, + { value: '2767', label: '沁水县' }, + { value: '4184', label: '城区' }, + { value: '140582', label: '其它区' }, + ], + }, + { + value: '2768', + label: '朔州', + children: [ + { value: '2769', label: '朔州市' }, + { value: '2770', label: '山阴县' }, + { value: '2771', label: '右玉县' }, + { value: '2772', label: '应县' }, + { value: '2773', label: '怀仁县' }, + { value: '4187', label: '平鲁区' }, + { value: '4188', label: '朔城区' }, + { value: '140625', label: '其它区' }, + ], + }, + { + value: '2774', + label: '晋中', + children: [ + { value: '2783', label: '和顺县' }, + { value: '2775', label: '晋中市' }, + { value: '2776', label: '介休市' }, + { value: '2777', label: '昔阳县' }, + { value: '2778', label: '灵石县' }, + { value: '2779', label: '祁县' }, + { value: '2780', label: '左权县' }, + { value: '2781', label: '寿阳县' }, + { value: '2782', label: '太谷县' }, + { value: '2784', label: '平遥县' }, + { value: '2785', label: '榆社县' }, + { value: '4185', label: '榆次区' }, + { value: '140782', label: '其它区' }, + ], + }, + { + value: '2786', + label: '忻州', + children: [ + { value: '2795', label: '静乐县' }, + { value: '2787', label: '忻州市' }, + { value: '2788', label: '原平市' }, + { value: '2789', label: '代县' }, + { value: '2790', label: '神池县' }, + { value: '2791', label: '五寨县' }, + { value: '2792', label: '五台县' }, + { value: '2793', label: '偏关县' }, + { value: '2794', label: '宁武县' }, + { value: '2796', label: '繁峙县' }, + { value: '2797', label: '河曲县' }, + { value: '2798', label: '保德县' }, + { value: '2799', label: '定襄县' }, + { value: '2800', label: '岢岚县' }, + { value: '4194', label: '忻府区' }, + { value: '140982', label: '其它区' }, + ], + }, + { + value: '2801', + label: '临汾', + children: [ + { value: '2808', label: '大宁县' }, + { value: '2802', label: '临汾市' }, + { value: '2803', label: '侯马市' }, + { value: '2804', label: '霍州市' }, + { value: '2805', label: '汾西县' }, + { value: '2806', label: '吉县' }, + { value: '2807', label: '安泽县' }, + { value: '2809', label: '浮山县' }, + { value: '2810', label: '古县' }, + { value: '2811', label: '隰县' }, + { value: '2812', label: '襄汾县' }, + { value: '2813', label: '翼城县' }, + { value: '2814', label: '永和县' }, + { value: '2815', label: '乡宁县' }, + { value: '2816', label: '曲沃县' }, + { value: '2817', label: '洪洞县' }, + { value: '2818', label: '蒲县' }, + { value: '4186', label: '尧都区' }, + { value: '141083', label: '其它区' }, + ], + }, + { + value: '2819', + label: '运城', + children: [ + { value: '2820', label: '运城市' }, + { value: '2821', label: '河津市' }, + { value: '2822', label: '永济市' }, + { value: '2823', label: '闻喜县' }, + { value: '2824', label: '新绛县' }, + { value: '2825', label: '平陆县' }, + { value: '2826', label: '垣曲县' }, + { value: '2827', label: '绛县' }, + { value: '2828', label: '稷山县' }, + { value: '2829', label: '芮城县' }, + { value: '2830', label: '夏县' }, + { value: '2831', label: '万荣县' }, + { value: '2832', label: '临猗县' }, + { value: '4198', label: '盐湖区' }, + { value: '140883', label: '其它区' }, + ], + }, + { + value: '2833', + label: '吕梁', + children: [ + { value: '3701', label: '吕梁市' }, + { value: '2835', label: '孝义市' }, + { value: '2836', label: '汾阳市' }, + { value: '2837', label: '文水县' }, + { value: '2838', label: '中阳县' }, + { value: '2839', label: '兴县' }, + { value: '2840', label: '临县' }, + { value: '2841', label: '方山县' }, + { value: '2842', label: '柳林县' }, + { value: '2843', label: '岚县' }, + { value: '2844', label: '交口县' }, + { value: '2845', label: '交城县' }, + { value: '2846', label: '石楼县' }, + { value: '4385', label: '离石区' }, + { value: '141183', label: '其它区' }, + ], + }, + ], + value: '2728', + label: '山西', + }, + { + children: [ + { + value: '2848', + label: '济南', + children: [ + { value: '2849', label: '济南市' }, + { value: '2850', label: '章丘市' }, + { value: '2851', label: '平阴县' }, + { value: '2852', label: '济阳县' }, + { value: '2853', label: '商河县' }, + { value: '4140', label: '长清区' }, + { value: '4141', label: '槐荫区' }, + { value: '4142', label: '历城区' }, + { value: '4143', label: '市中区' }, + { value: '4144', label: '天桥区' }, + { value: '4379', label: '历下区' }, + { value: '370182', label: '其它区' }, + ], + }, + { + value: '2854', + label: '青岛', + children: [ + { value: '2855', label: '青岛市' }, + { value: '2856', label: '胶南市' }, + { value: '2857', label: '胶州市' }, + { value: '2858', label: '平度市' }, + { value: '2859', label: '莱西市' }, + { value: '2860', label: '即墨市' }, + { value: '4152', label: '城阳区' }, + { value: '4153', label: '黄岛区' }, + { value: '4154', label: '崂山区' }, + { value: '4155', label: '李沧区' }, + { value: '4156', label: '市北区' }, + { value: '4157', label: '四方区' }, + { value: '4381', label: '市南区' }, + { value: '370286', label: '其它区' }, + ], + }, + { + value: '2861', + label: '淄博', + children: [ + { value: '2862', label: '淄博市' }, + { value: '2863', label: '桓台县' }, + { value: '2864', label: '高青县' }, + { value: '2865', label: '沂源县' }, + { value: '4174', label: '博山区' }, + { value: '4175', label: '临淄区' }, + { value: '4176', label: '张店区' }, + { value: '4177', label: '周村区' }, + { value: '4178', label: '淄川区' }, + { value: '370324', label: '其它区' }, + ], + }, + { + value: '2866', + label: '枣庄', + children: [ + { value: '2867', label: '枣庄市' }, + { value: '2868', label: '滕州市' }, + { value: '4170', label: '山亭区' }, + { value: '4171', label: '市中区' }, + { value: '4172', label: '薛城区' }, + { value: '4173', label: '峄城区' }, + { value: '4383', label: '台儿庄区' }, + { value: '370482', label: '其它区' }, + ], + }, + { + value: '2869', + label: '东营', + children: [ + { value: '2870', label: '东营市' }, + { value: '2871', label: '垦利县' }, + { value: '2872', label: '广饶县' }, + { value: '2873', label: '利津县' }, + { value: '4137', label: '东营区' }, + { value: '4138', label: '河口区' }, + { value: '370591', label: '其它区' }, + ], + }, + { + value: '2874', + label: '潍坊', + children: [ + { value: '2875', label: '潍坊市' }, + { value: '2876', label: '青州市' }, + { value: '2877', label: '诸城市' }, + { value: '2878', label: '寿光市' }, + { value: '2879', label: '安丘市' }, + { value: '2880', label: '高密市' }, + { value: '2881', label: '昌邑市' }, + { value: '2882', label: '昌乐县' }, + { value: '2883', label: '临朐县' }, + { value: '4163', label: '坊子区' }, + { value: '4164', label: '寒亭区' }, + { value: '4165', label: '潍城区' }, + { value: '4382', label: '奎文区' }, + { value: '10011', label: '开发区' }, + { value: '370787', label: '其它区' }, + ], + }, + { + value: '2884', + label: '烟台', + children: [ + { value: '2885', label: '烟台市' }, + { value: '2886', label: '龙口市' }, + { value: '2887', label: '莱阳市' }, + { value: '2888', label: '莱州市' }, + { value: '2889', label: '招远市' }, + { value: '2890', label: '蓬莱市' }, + { value: '2891', label: '栖霞市' }, + { value: '2892', label: '海阳市' }, + { value: '2893', label: '长岛县' }, + { value: '4166', label: '福山区' }, + { value: '4167', label: '莱山区' }, + { value: '4168', label: '牟平区' }, + { value: '4169', label: '芝罘区' }, + { value: '370688', label: '其它区' }, + ], + }, + { + value: '2894', + label: '威海', + children: [ + { value: '2895', label: '威海市' }, + { value: '2896', label: '乳山市' }, + { value: '2897', label: '文登市' }, + { value: '2898', label: '荣成市' }, + { value: '4162', label: '环翠区' }, + { value: '371084', label: '其它区' }, + ], + }, + { + value: '2899', + label: '济宁', + children: [ + { value: '2900', label: '济宁市' }, + { value: '2901', label: '曲阜市' }, + { value: '2902', label: '兖州市' }, + { value: '2903', label: '邹城市' }, + { value: '2904', label: '鱼台县' }, + { value: '2905', label: '金乡县' }, + { value: '2906', label: '嘉祥县' }, + { value: '2907', label: '微山县' }, + { value: '2908', label: '汶上县' }, + { value: '2909', label: '泗水县' }, + { value: '2910', label: '梁山县' }, + { value: '4145', label: '任城区' }, + { value: '4146', label: '市中区' }, + { value: '370884', label: '其它区' }, + ], + }, + { + value: '2911', + label: '泰安', + children: [ + { value: '2912', label: '泰安市' }, + { value: '2913', label: '新泰市' }, + { value: '2914', label: '肥城市' }, + { value: '2915', label: '宁阳县' }, + { value: '2916', label: '东平县' }, + { value: '4160', label: '岱岳区' }, + { value: '4161', label: '泰山区' }, + { value: '370984', label: '其它区' }, + ], + }, + { + value: '2917', + label: '日照', + children: [ + { value: '2918', label: '日照市' }, + { value: '2919', label: '五莲县' }, + { value: '2920', label: '莒县' }, + { value: '4158', label: '东港区' }, + { value: '4159', label: '岚山区' }, + { value: '371123', label: '其它区' }, + ], + }, + { + value: '2921', + label: '莱芜', + children: [ + { value: '2922', label: '莱芜市' }, + { value: '4147', label: '钢城区' }, + { value: '4148', label: '莱城区' }, + { value: '371204', label: '其它区' }, + ], + }, + { + value: '2923', + label: '德州', + children: [ + { value: '2929', label: '齐河县' }, + { value: '2924', label: '德州市' }, + { value: '2925', label: '乐陵市' }, + { value: '2926', label: '禹城市' }, + { value: '2927', label: '陵县' }, + { value: '2928', label: '宁津县' }, + { value: '2930', label: '武城县' }, + { value: '2931', label: '庆云县' }, + { value: '2932', label: '平原县' }, + { value: '2933', label: '夏津县' }, + { value: '2934', label: '临邑县' }, + { value: '4136', label: '德城区' }, + { value: '371483', label: '其它区' }, + ], + }, + { + value: '2935', + label: '临沂', + children: [ + { value: '2939', label: '沂水县' }, + { value: '2940', label: '苍山县' }, + { value: '2942', label: '平邑县' }, + { value: '2943', label: '莒南县' }, + { value: '2944', label: '蒙阴县' }, + { value: '2945', label: '临沭县' }, + { value: '2941', label: '费县' }, + { value: '2936', label: '临沂市' }, + { value: '2937', label: '沂南县' }, + { value: '2938', label: '郯城县' }, + { value: '4150', label: '兰山区' }, + { value: '4151', label: '罗庄区' }, + { value: '4380', label: '河东区' }, + { value: '371330', label: '其它区' }, + ], + }, + { + value: '2946', + label: '聊城', + children: [ + { value: '2947', label: '聊城市' }, + { value: '2948', label: '临清市' }, + { value: '2949', label: '高唐县' }, + { value: '2950', label: '阳谷县' }, + { value: '2951', label: '茌平县' }, + { value: '2952', label: '莘县' }, + { value: '2953', label: '东阿县' }, + { value: '2954', label: '冠县' }, + { value: '4149', label: '东昌府区' }, + { value: '371582', label: '其它区' }, + ], + }, + { + value: '2955', + label: '滨州', + children: [ + { value: '2956', label: '滨州市' }, + { value: '2957', label: '邹平县' }, + { value: '2958', label: '沾化县' }, + { value: '2959', label: '惠民县' }, + { value: '2960', label: '博兴县' }, + { value: '2961', label: '阳信县' }, + { value: '2962', label: '无棣县' }, + { value: '4135', label: '滨城区' }, + { value: '10012', label: '经济开发区' }, + { value: '371627', label: '其它区' }, + ], + }, + { + value: '2963', + label: '菏泽', + children: [ + { value: '2964', label: '菏泽市' }, + { value: '2965', label: '鄄城县' }, + { value: '2966', label: '单县' }, + { value: '2967', label: '郓城县' }, + { value: '2968', label: '曹县' }, + { value: '2969', label: '定陶县' }, + { value: '2970', label: '巨野县' }, + { value: '2971', label: '东明县' }, + { value: '2972', label: '成武县' }, + { value: '4139', label: '牡丹区' }, + { value: '371729', label: '其它区' }, + ], + }, + ], + value: '2847', + label: '山东', + }, + { + children: [ + { + value: '1003', + label: '合肥', + children: [ + { value: '1004', label: '合肥市' }, + { value: '1005', label: '长丰县' }, + { value: '1006', label: '肥东县' }, + { value: '1007', label: '肥西县' }, + { value: '4448', label: '包河区' }, + { value: '4449', label: '庐阳区' }, + { value: '4450', label: '蜀山区' }, + { value: '4451', label: '瑶海区' }, + { value: '340191', label: '中区' }, + { value: '340192', label: '其它区' }, + ], + }, + { + value: '1008', + label: '芜湖', + children: [ + { value: '1009', label: '芜湖市' }, + { value: '1010', label: '芜湖县' }, + { value: '1011', label: '南陵县' }, + { value: '1012', label: '繁昌县' }, + { value: '4471', label: '镜湖区' }, + { value: '4472', label: '鸠江区' }, + { value: '4473', label: '三山区' }, + { value: '4474', label: '弋江区' }, + { value: '340224', label: '其它区' }, + ], + }, + { + value: '1013', + label: '蚌埠', + children: [ + { value: '1014', label: '蚌埠市' }, + { value: '1015', label: '怀远县' }, + { value: '1016', label: '固镇县' }, + { value: '1017', label: '五河县' }, + { value: '4436', label: '蚌山区' }, + { value: '4437', label: '淮上区' }, + { value: '4438', label: '龙子湖区' }, + { value: '4439', label: '禹会区' }, + { value: '340324', label: '其它区' }, + ], + }, + { + value: '1018', + label: '淮南', + children: [ + { value: '1019', label: '淮南市' }, + { value: '1020', label: '凤台县' }, + { value: '4455', label: '八公山区' }, + { value: '4456', label: '大通区' }, + { value: '4457', label: '潘集区' }, + { value: '4458', label: '田家庵区' }, + { value: '4459', label: '谢家集区' }, + { value: '340422', label: '其它区' }, + ], + }, + { + value: '1021', + label: '马鞍山', + children: [ + { value: '1022', label: '马鞍山市' }, + { value: '1023', label: '当涂县' }, + { value: '4389', label: '雨山区' }, + { value: '4465', label: '花山区' }, + { value: '4466', label: '金家庄区' }, + { value: '340522', label: '其它区' }, + ], + }, + { + value: '1024', + label: '淮北', + children: [ + { value: '1025', label: '淮北市' }, + { value: '1026', label: '濉溪县' }, + { value: '4452', label: '杜集区' }, + { value: '4453', label: '烈山区' }, + { value: '4454', label: '相山区' }, + { value: '340622', label: '其它区' }, + ], + }, + { + value: '1027', + label: '铜陵', + children: [ + { value: '1028', label: '铜陵市' }, + { value: '1029', label: '铜陵县' }, + { value: '4468', label: '郊区' }, + { value: '4469', label: '狮子山区' }, + { value: '4470', label: '铜官山区' }, + { value: '340722', label: '其它区' }, + ], + }, + { + value: '1030', + label: '安庆', + children: [ + { value: '1031', label: '安庆市' }, + { value: '1032', label: '桐城市' }, + { value: '1033', label: '宿松县' }, + { value: '1034', label: '枞阳县' }, + { value: '1035', label: '太湖县' }, + { value: '1036', label: '怀宁县' }, + { value: '1037', label: '岳西县' }, + { value: '1038', label: '望江县' }, + { value: '1039', label: '潜山县' }, + { value: '4433', label: '大观区' }, + { value: '4434', label: '宜秀区' }, + { value: '4435', label: '迎江区' }, + { value: '340882', label: '其它区' }, + ], + }, + { + value: '1040', + label: '黄山', + children: [ + { value: '1043', label: '歙县' }, + { value: '1041', label: '黄山市' }, + { value: '1042', label: '休宁县' }, + { value: '1044', label: '祁门县' }, + { value: '1045', label: '黟县' }, + { value: '4460', label: '黄山区' }, + { value: '4461', label: '徽州区' }, + { value: '4462', label: '屯溪区' }, + { value: '341025', label: '其它区' }, + ], + }, + { + value: '1046', + label: '滁州', + children: [ + { value: '1047', label: '滁州市' }, + { value: '1048', label: '天长市' }, + { value: '1049', label: '明光市' }, + { value: '1050', label: '全椒县' }, + { value: '1051', label: '来安县' }, + { value: '1052', label: '定远县' }, + { value: '1053', label: '凤阳县' }, + { value: '4443', label: '琅琊区' }, + { value: '4444', label: '南谯区' }, + { value: '341183', label: '其它区' }, + ], + }, + { + value: '1054', + label: '阜阳', + children: [ + { value: '1055', label: '阜阳市' }, + { value: '1056', label: '界首市' }, + { value: '1057', label: '临泉县' }, + { value: '1058', label: '颍上县' }, + { value: '1059', label: '阜南县' }, + { value: '1060', label: '太和县' }, + { value: '4445', label: '颍东区' }, + { value: '4446', label: '颍泉区' }, + { value: '4447', label: '颍州区' }, + { value: '341283', label: '其它区' }, + ], + }, + { + value: '1061', + label: '宿州', + children: [ + { value: '1066', label: '灵璧县' }, + { value: '1062', label: '宿州市' }, + { value: '1063', label: '萧县' }, + { value: '1064', label: '泗县' }, + { value: '1065', label: '砀山县' }, + { value: '4467', label: '埇桥区' }, + { value: '341325', label: '其它区' }, + ], + }, + { + value: '1067', + label: '巢湖', + children: [ + { value: '1068', label: '巢湖市' }, + { value: '1069', label: '含山县' }, + { value: '1070', label: '无为县' }, + { value: '1071', label: '庐江县' }, + { value: '1072', label: '和县' }, + { value: '4441', label: '居巢区' }, + ], + }, + { + value: '1073', + label: '六安', + children: [ + { value: '1074', label: '六安市' }, + { value: '1075', label: '寿县' }, + { value: '1076', label: '霍山县' }, + { value: '1077', label: '霍邱县' }, + { value: '1078', label: '舒城县' }, + { value: '1079', label: '金寨县' }, + { value: '4463', label: '金安区' }, + { value: '4464', label: '裕安区' }, + { value: '341526', label: '其它区' }, + ], + }, + { + value: '1080', + label: '亳州', + children: [ + { value: '1081', label: '亳州市' }, + { value: '1082', label: '利辛县' }, + { value: '1083', label: '涡阳县' }, + { value: '1084', label: '蒙城县' }, + { value: '4440', label: '谯城区' }, + { value: '341624', label: '其它区' }, + ], + }, + { + value: '1085', + label: '池州', + children: [ + { value: '1086', label: '池州市' }, + { value: '1087', label: '东至县' }, + { value: '1088', label: '石台县' }, + { value: '1089', label: '青阳县' }, + { value: '4442', label: '贵池区' }, + { value: '341724', label: '其它区' }, + ], + }, + { + value: '1090', + label: '宣城', + children: [ + { value: '1091', label: '宣城市' }, + { value: '1092', label: '宁国市' }, + { value: '1093', label: '广德县' }, + { value: '1094', label: '郎溪县' }, + { value: '1095', label: '泾县' }, + { value: '1096', label: '旌德县' }, + { value: '1097', label: '绩溪县' }, + { value: '4475', label: '宣州区' }, + { value: '341882', label: '其它区' }, + ], + }, + ], + value: '1002', + label: '安徽', + }, + { + children: [ + { + value: '1099', + label: '北京', + children: [ + { value: '1100', label: '北京市' }, + { value: '1101', label: '密云县' }, + { value: '1102', label: '延庆县' }, + { value: '4390', label: '昌平区' }, + { value: '4391', label: '怀柔区' }, + { value: '4476', label: '朝阳区' }, + { value: '4477', label: '崇文区' }, + { value: '4478', label: '大兴区' }, + { value: '4479', label: '东城区' }, + { value: '4480', label: '房山区' }, + { value: '4481', label: '丰台区' }, + { value: '4482', label: '海淀区' }, + { value: '4483', label: '门头沟区' }, + { value: '4484', label: '平谷区' }, + { value: '4485', label: '石景山区' }, + { value: '4486', label: '顺义区' }, + { value: '4487', label: '通州区' }, + { value: '4488', label: '西城区' }, + { value: '4489', label: '宣武区' }, + { value: '110230', label: '其它区' }, + ], + }, + ], + value: '1098', + label: '北京', + }, + { + children: [ + { + value: '1104', + label: '福州', + children: [ + { value: '1107', label: '长乐市' }, + { value: '1105', label: '福州市' }, + { value: '1106', label: '福清市' }, + { value: '1108', label: '闽侯县' }, + { value: '1109', label: '闽清县' }, + { value: '1110', label: '永泰县' }, + { value: '1111', label: '连江县' }, + { value: '1112', label: '罗源县' }, + { value: '1113', label: '平潭县' }, + { value: '4392', label: '鼓楼区' }, + { value: '4490', label: '仓山区' }, + { value: '4491', label: '晋安区' }, + { value: '4492', label: '马尾区' }, + { value: '4493', label: '台江区' }, + { value: '350183', label: '其它区' }, + ], + }, + { + value: '1114', + label: '厦门', + children: [ + { value: '1115', label: '厦门市' }, + { value: '4505', label: '海沧区' }, + { value: '4506', label: '湖里区' }, + { value: '4507', label: '集美区' }, + { value: '4508', label: '思明区' }, + { value: '4509', label: '同安区' }, + { value: '4510', label: '翔安区' }, + { value: '350214', label: '其它区' }, + ], + }, + { + value: '1116', + label: '三明', + children: [ + { value: '1120', label: '将乐县' }, + { value: '1117', label: '三明市' }, + { value: '1118', label: '永安市' }, + { value: '1119', label: '明溪县' }, + { value: '1121', label: '大田县' }, + { value: '1122', label: '宁化县' }, + { value: '1123', label: '建宁县' }, + { value: '1124', label: '沙县' }, + { value: '1125', label: '尤溪县' }, + { value: '1126', label: '清流县' }, + { value: '1127', label: '泰宁县' }, + { value: '4394', label: '三元区' }, + { value: '4504', label: '梅列区' }, + { value: '350482', label: '其它区' }, + ], + }, + { + value: '1128', + label: '莆田', + children: [ + { value: '1129', label: '莆田市' }, + { value: '1130', label: '仙游县' }, + { value: '4393', label: '涵江区' }, + { value: '4497', label: '城厢区' }, + { value: '4498', label: '荔城区' }, + { value: '4499', label: '秀屿区' }, + { value: '350323', label: '其它区' }, + ], + }, + { + value: '1131', + label: '泉州', + children: [ + { value: '1132', label: '泉州市' }, + { value: '1133', label: '石狮市' }, + { value: '1134', label: '晋江市' }, + { value: '1135', label: '南安市' }, + { value: '1136', label: '惠安县' }, + { value: '1137', label: '永春县' }, + { value: '1138', label: '安溪县' }, + { value: '1139', label: '德化县' }, + { value: '1140', label: '金门县' }, + { value: '4500', label: '丰泽区' }, + { value: '4501', label: '鲤城区' }, + { value: '4502', label: '洛江区' }, + { value: '4503', label: '泉港区' }, + { value: '350584', label: '其它区' }, + ], + }, + { + value: '1141', + label: '漳州', + children: [ + { value: '1142', label: '漳州市' }, + { value: '1143', label: '龙海市' }, + { value: '1144', label: '平和县' }, + { value: '1145', label: '南靖县' }, + { value: '1146', label: '诏安县' }, + { value: '1147', label: '漳浦县' }, + { value: '1148', label: '华安县' }, + { value: '1149', label: '东山县' }, + { value: '1150', label: '长泰县' }, + { value: '1151', label: '云霄县' }, + { value: '4511', label: '龙文区' }, + { value: '4512', label: '芗城区' }, + { value: '350682', label: '其它区' }, + ], + }, + { + value: '1152', + label: '南平', + children: [ + { value: '1153', label: '南平市' }, + { value: '1154', label: '建瓯市' }, + { value: '1155', label: '邵武市' }, + { value: '1156', label: '武夷山市' }, + { value: '1157', label: '建阳市' }, + { value: '1158', label: '松溪县' }, + { value: '1159', label: '光泽县' }, + { value: '1160', label: '顺昌县' }, + { value: '1161', label: '浦城县' }, + { value: '1162', label: '政和县' }, + { value: '4495', label: '延平区' }, + { value: '350785', label: '其它区' }, + ], + }, + { + value: '1163', + label: '龙岩', + children: [ + { value: '1164', label: '龙岩市' }, + { value: '1165', label: '漳平市' }, + { value: '1166', label: '长汀县' }, + { value: '1167', label: '武平县' }, + { value: '1168', label: '上杭县' }, + { value: '1169', label: '永定县' }, + { value: '1170', label: '连城县' }, + { value: '4494', label: '新罗区' }, + { value: '350882', label: '其它区' }, + ], + }, + { + value: '1171', + label: '宁德', + children: [ + { value: '1173', label: '福安市' }, + { value: '1172', label: '宁德市' }, + { value: '1174', label: '福鼎市' }, + { value: '1175', label: '寿宁县' }, + { value: '1176', label: '霞浦县' }, + { value: '1177', label: '柘荣县' }, + { value: '1178', label: '屏南县' }, + { value: '1179', label: '古田县' }, + { value: '1180', label: '周宁县' }, + { value: '4496', label: '蕉城区' }, + { value: '350983', label: '其它区' }, + ], + }, + ], + value: '1103', + label: '福建', + }, + { + children: [ + { + value: '1182', + label: '兰州', + children: [ + { value: '1186', label: '皋兰县' }, + { value: '1183', label: '兰州市' }, + { value: '1184', label: '永登县' }, + { value: '1185', label: '榆中县' }, + { value: '4396', label: '七里河区' }, + { value: '4517', label: '安宁区' }, + { value: '4518', label: '城关区' }, + { value: '4519', label: '红古区' }, + { value: '4520', label: '西固区' }, + { value: '620124', label: '其它区' }, + ], + }, + { + value: '1187', + label: '金昌', + children: [ + { value: '1188', label: '金昌市' }, + { value: '1189', label: '永昌县' }, + { value: '4515', label: '金川区' }, + { value: '620322', label: '其它区' }, + ], + }, + { + value: '1190', + label: '白银', + children: [ + { value: '1191', label: '白银市' }, + { value: '1192', label: '靖远县' }, + { value: '1193', label: '景泰县' }, + { value: '1194', label: '会宁县' }, + { value: '4395', label: '白银区' }, + { value: '4513', label: '平川区' }, + { value: '620424', label: '其它区' }, + ], + }, + { + value: '1195', + label: '天水', + children: [ + { value: '1198', label: '甘谷县' }, + { value: '1196', label: '天水市' }, + { value: '1197', label: '武山县' }, + { value: '1199', label: '清水县' }, + { value: '1200', label: '秦安县' }, + { value: '1201', label: '张家川回族自治县' }, + { value: '4523', label: '北道区' }, + { value: '4524', label: '秦城区' }, + { value: '620502', label: '秦州区' }, + { value: '620503', label: '麦积区' }, + { value: '620526', label: '其它区' }, + ], + }, + { + value: '1202', + label: '嘉峪关', + children: [{ value: '1203', label: '嘉峪关市' }], + }, + { + value: '1204', + label: '武威', + children: [ + { value: '1205', label: '武威市' }, + { value: '1206', label: '民勤县' }, + { value: '1207', label: '古浪县' }, + { value: '1208', label: '天祝藏族自治县' }, + { value: '4525', label: '凉州区' }, + { value: '620624', label: '其它区' }, + ], + }, + { + value: '1209', + label: '张掖', + children: [ + { value: '1210', label: '张掖市' }, + { value: '1211', label: '民乐县' }, + { value: '1212', label: '山丹县' }, + { value: '1213', label: '临泽县' }, + { value: '1214', label: '高台县' }, + { value: '1215', label: '肃南裕固族自治县' }, + { value: '4526', label: '甘州区' }, + { value: '620726', label: '其它区' }, + ], + }, + { + value: '1216', + label: '平凉', + children: [ + { value: '1217', label: '平凉市' }, + { value: '1218', label: '灵台县' }, + { value: '1219', label: '静宁县' }, + { value: '1220', label: '崇信县' }, + { value: '1221', label: '华亭县' }, + { value: '1222', label: '泾川县' }, + { value: '1223', label: '庄浪县' }, + { value: '4521', label: '崆峒区' }, + { value: '620827', label: '其它区' }, + ], + }, + { + value: '1224', + label: '酒泉', + children: [ + { value: '1225', label: '酒泉市' }, + { value: '1226', label: '玉门市' }, + { value: '1227', label: '敦煌市' }, + { value: '1228', label: '瓜州县' }, + { value: '1229', label: '金塔县' }, + { value: '1230', label: '阿克塞哈萨克族自治县' }, + { value: '1231', label: '肃北蒙古族自治县' }, + { value: '4516', label: '肃州区' }, + { value: '620922', label: '安西县' }, + { value: '620983', label: '其它区' }, + ], + }, + { + value: '1232', + label: '庆阳', + children: [ + { value: '1233', label: '庆阳市' }, + { value: '1234', label: '庆城县' }, + { value: '1235', label: '镇原县' }, + { value: '1236', label: '合水县' }, + { value: '1237', label: '华池县' }, + { value: '1238', label: '环县' }, + { value: '1239', label: '宁县' }, + { value: '1240', label: '正宁县' }, + { value: '4522', label: '西峰区' }, + { value: '621028', label: '其它区' }, + ], + }, + { + value: '1241', + label: '定西', + children: [ + { value: '1247', label: '漳县' }, + { value: '1242', label: '定西市' }, + { value: '1243', label: '岷县' }, + { value: '1244', label: '渭源县' }, + { value: '1245', label: '陇西县' }, + { value: '1246', label: '通渭县' }, + { value: '1248', label: '临洮县' }, + { value: '4514', label: '安定区' }, + { value: '621127', label: '其它区' }, + ], + }, + { + value: '1249', + label: '陇南', + children: [ + { value: '1253', label: '武都区' }, + { value: '3784', label: '陇南市' }, + { value: '1250', label: '成县' }, + { value: '1251', label: '礼县' }, + { value: '1252', label: '康县' }, + { value: '1254', label: '文县' }, + { value: '1255', label: '两当县' }, + { value: '1256', label: '徽县' }, + { value: '1257', label: '宕昌县' }, + { value: '1258', label: '西和县' }, + { value: '621229', label: '其它区' }, + ], + }, + { + value: '1259', + label: '甘南藏族自治州', + children: [ + { value: '1260', label: '合作市' }, + { value: '1261', label: '临潭县' }, + { value: '1262', label: '卓尼县' }, + { value: '1263', label: '舟曲县' }, + { value: '1264', label: '迭部县' }, + { value: '1265', label: '玛曲县' }, + { value: '1266', label: '碌曲县' }, + { value: '1267', label: '夏河县' }, + ], + }, + { + value: '1268', + label: '临夏回族自治州', + children: [ + { value: '1273', label: '广河县' }, + { value: '1269', label: '临夏市' }, + { value: '1270', label: '临夏县' }, + { value: '1271', label: '康乐县' }, + { value: '1272', label: '永靖县' }, + { value: '1274', label: '和政县' }, + { value: '1275', label: '东乡族自治县' }, + { value: '1276', label: '积石山保安族东乡族撒拉族自治县' }, + ], + }, + ], + value: '1181', + label: '甘肃', + }, + { + children: [ + { + value: '1278', + label: '南宁', + children: [ + { value: '1280', label: '邕宁区' }, + { value: '1279', label: '南宁市' }, + { value: '1281', label: '武鸣县' }, + { value: '1282', label: '隆安县' }, + { value: '1283', label: '马山县' }, + { value: '1284', label: '上林县' }, + { value: '1285', label: '宾阳县' }, + { value: '1286', label: '横县' }, + { value: '4407', label: '钦南区' }, + { value: '4592', label: '江南区' }, + { value: '4593', label: '良庆区' }, + { value: '4594', label: '青秀区' }, + { value: '4595', label: '西乡塘区' }, + { value: '4596', label: '兴宁区' }, + { value: '4597', label: '钦北区' }, + { value: '4598', label: '长洲区' }, + { value: '4599', label: '蝶山区' }, + { value: '4600', label: '万秀区' }, + { value: '4601', label: '玉州区' }, + { value: '450128', label: '其它区' }, + ], + }, + { + value: '1287', + label: '柳州', + children: [ + { value: '1288', label: '柳州市' }, + { value: '1289', label: '柳江县' }, + { value: '1290', label: '柳城县' }, + { value: '1291', label: '鹿寨县' }, + { value: '1292', label: '融安县' }, + { value: '1293', label: '融水苗族自治县' }, + { value: '1294', label: '三江侗族自治县' }, + { value: '4406', label: '柳南区' }, + { value: '4589', label: '城中区' }, + { value: '4590', label: '柳北区' }, + { value: '4591', label: '鱼峰区' }, + { value: '450227', label: '其它区' }, + ], + }, + { + value: '1295', + label: '桂林', + children: [ + { value: '1296', label: '桂林市' }, + { value: '1297', label: '阳朔县' }, + { value: '1298', label: '临桂县' }, + { value: '1299', label: '灵川县' }, + { value: '1300', label: '全州县' }, + { value: '1301', label: '平乐县' }, + { value: '1302', label: '兴安县' }, + { value: '1303', label: '灌阳县' }, + { value: '1304', label: '荔蒲县' }, + { value: '1305', label: '资源县' }, + { value: '1306', label: '永福县' }, + { value: '1307', label: '龙胜各族自治县' }, + { value: '1308', label: '恭城瑶族自治县' }, + { value: '4405', label: '象山区' }, + { value: '4582', label: '叠彩区' }, + { value: '4583', label: '七星区' }, + { value: '4584', label: '秀峰区' }, + { value: '4585', label: '雁山区' }, + { value: '450331', label: '荔浦县' }, + { value: '450333', label: '其它区' }, + ], + }, + { + value: '1309', + label: '梧州', + children: [ + { value: '1313', label: '藤县' }, + { value: '1310', label: '梧州市' }, + { value: '1311', label: '岑溪市' }, + { value: '1312', label: '苍梧县' }, + { value: '1314', label: '蒙山县' }, + { value: '450482', label: '其它区' }, + ], + }, + { + value: '1315', + label: '北海', + children: [ + { value: '1316', label: '北海市' }, + { value: '1317', label: '合浦县' }, + { value: '4574', label: '海城区' }, + { value: '4575', label: '铁山港区' }, + { value: '4576', label: '银海区' }, + { value: '450522', label: '其它区' }, + ], + }, + { + value: '1318', + label: '防城港', + children: [ + { value: '1319', label: '防城港市' }, + { value: '1320', label: '东兴市' }, + { value: '1321', label: '上思县' }, + { value: '4577', label: '防城区' }, + { value: '4578', label: '港口区' }, + { value: '450682', label: '其它区' }, + ], + }, + { + value: '1322', + label: '钦州', + children: [ + { value: '1325', label: '浦北县' }, + { value: '1323', label: '钦州市' }, + { value: '1324', label: '灵山县' }, + { value: '450723', label: '其它区' }, + ], + }, + { + value: '1326', + label: '贵港', + children: [ + { value: '1327', label: '贵港市' }, + { value: '1328', label: '桂平市' }, + { value: '1329', label: '平南县' }, + { value: '4579', label: '港北区' }, + { value: '4580', label: '港南区' }, + { value: '4581', label: '覃塘区' }, + { value: '450882', label: '其它区' }, + ], + }, + { + value: '1330', + label: '玉林', + children: [ + { value: '1331', label: '玉林市' }, + { value: '1332', label: '北流市' }, + { value: '1333', label: '容县' }, + { value: '1334', label: '陆川县' }, + { value: '1335', label: '博白县' }, + { value: '1336', label: '兴业县' }, + { value: '450982', label: '其它区' }, + ], + }, + { + value: '1337', + label: '百色', + children: [ + { value: '1338', label: '百色市' }, + { value: '1339', label: '凌云县' }, + { value: '1340', label: '平果县' }, + { value: '1341', label: '西林县' }, + { value: '1342', label: '乐业县' }, + { value: '1343', label: '德保县' }, + { value: '1344', label: '田林县' }, + { value: '1345', label: '田阳县' }, + { value: '1346', label: '靖西县' }, + { value: '1347', label: '田东县' }, + { value: '1348', label: '那坡县' }, + { value: '1349', label: '隆林各族自治县' }, + { value: '4573', label: '右江区' }, + { value: '451032', label: '其它区' }, + ], + }, + { + value: '1350', + label: '贺州', + children: [ + { value: '1351', label: '贺州市' }, + { value: '1352', label: '钟山县' }, + { value: '1353', label: '昭平县' }, + { value: '1354', label: '富川瑶族自治县' }, + { value: '4587', label: '八步区' }, + { value: '451124', label: '其它区' }, + ], + }, + { + value: '1355', + label: '河池', + children: [ + { value: '1356', label: '河池市' }, + { value: '1357', label: '宜州市' }, + { value: '1358', label: '天峨县' }, + { value: '1359', label: '凤山县' }, + { value: '1360', label: '南丹县' }, + { value: '1361', label: '东兰县' }, + { value: '1362', label: '都安瑶族自治县' }, + { value: '1363', label: '罗城仫佬族自治县' }, + { value: '1364', label: '巴马瑶族自治县' }, + { value: '1365', label: '环江毛南族自治县' }, + { value: '1366', label: '大化瑶族自治县' }, + { value: '4586', label: '金城江区' }, + { value: '451282', label: '其它区' }, + ], + }, + { + value: '1367', + label: '来宾', + children: [ + { value: '1368', label: '来宾市' }, + { value: '1369', label: '合山市' }, + { value: '1370', label: '象州县' }, + { value: '1371', label: '武宣县' }, + { value: '1372', label: '忻城县' }, + { value: '1373', label: '金秀瑶族自治县' }, + { value: '4588', label: '兴宾区' }, + { value: '451382', label: '其它区' }, + ], + }, + { + value: '1374', + label: '崇左', + children: [ + { value: '1375', label: '崇左市' }, + { value: '1376', label: '凭祥市' }, + { value: '1377', label: '扶绥县' }, + { value: '1378', label: '大新县' }, + { value: '1379', label: '天等县' }, + { value: '1380', label: '宁明县' }, + { value: '1381', label: '龙州县' }, + { value: '4404', label: '江洲区' }, + { value: '451402', label: '江州区' }, + { value: '451482', label: '其它区' }, + ], + }, + ], + value: '1277', + label: '广西', + }, + { + children: [ + { + value: '1383', + label: '贵阳', + children: [ + { value: '1385', label: '清镇市' }, + { value: '1384', label: '贵阳市' }, + { value: '1386', label: '开阳县' }, + { value: '1387', label: '修文县' }, + { value: '1388', label: '息烽县' }, + { value: '4408', label: '南明区' }, + { value: '4603', label: '白云区' }, + { value: '4604', label: '花溪区' }, + { value: '4605', label: '乌当区' }, + { value: '4606', label: '小河区' }, + { value: '4607', label: '云岩区' }, + { value: '520151', label: '金阳开发区' }, + { value: '520182', label: '其它区' }, + ], + }, + { + value: '1389', + label: '六盘水', + children: [ + { value: '1390', label: '六盘水市' }, + { value: '1391', label: '水城县' }, + { value: '1392', label: '盘县' }, + { value: '1393', label: '六枝特区' }, + { value: '4608', label: '六枝特区' }, + { value: '4609', label: '钟山区' }, + { value: '520223', label: '其它区' }, + ], + }, + { + value: '1394', + label: '遵义', + children: [ + { value: '1397', label: '仁怀市' }, + { value: '1395', label: '遵义市' }, + { value: '1396', label: '赤水市' }, + { value: '1398', label: '遵义县' }, + { value: '1399', label: '绥阳县' }, + { value: '1400', label: '桐梓县' }, + { value: '1401', label: '习水县' }, + { value: '1402', label: '凤冈县' }, + { value: '1403', label: '正安县' }, + { value: '1404', label: '余庆县' }, + { value: '1405', label: '湄潭县' }, + { value: '1406', label: '道真仡佬族苗族自治县' }, + { value: '1407', label: '务川仡佬族苗族自治县' }, + { value: '4611', label: '红花岗区' }, + { value: '4612', label: '汇川区' }, + { value: '520383', label: '其它区' }, + ], + }, + { + value: '1408', + label: '安顺', + children: [ + { value: '1409', label: '安顺市' }, + { value: '1410', label: '普定县' }, + { value: '1411', label: '平坝县' }, + { value: '1412', label: '镇宁布依族苗族自治县' }, + { value: '1413', label: '紫云苗族布依族自治县' }, + { value: '1414', label: '关岭布依族苗族自治县' }, + { value: '4602', label: '西秀区' }, + { value: '520426', label: '其它区' }, + ], + }, + { + value: '1415', + label: '铜仁', + children: [ + { value: '1416', label: '铜仁市' }, + { value: '1417', label: '德江县' }, + { value: '1418', label: '江口县' }, + { value: '1419', label: '思南县' }, + { value: '1420', label: '石阡县' }, + { value: '1421', label: '玉屏侗族自治县' }, + { value: '1422', label: '松桃苗族自治县' }, + { value: '1423', label: '印江土家族苗族自治县' }, + { value: '1424', label: '沿河土家族自治县' }, + { value: '1425', label: '万山特区' }, + { value: '4610', label: '万山特区' }, + ], + }, + { + value: '1426', + label: '毕节', + children: [ + { value: '1427', label: '毕节市' }, + { value: '1428', label: '黔西县' }, + { value: '1429', label: '大方县' }, + { value: '1430', label: '织金县' }, + { value: '1431', label: '金沙县' }, + { value: '1432', label: '赫章县' }, + { value: '1433', label: '纳雍县' }, + { value: '1434', label: '威宁彝族回族苗族自治县' }, + ], + }, + { + value: '1435', + label: '黔西南布依族苗族自治州', + children: [ + { value: '1436', label: '兴义市' }, + { value: '1437', label: '望谟县' }, + { value: '1438', label: '兴仁县' }, + { value: '1439', label: '普安县' }, + { value: '1440', label: '册亨县' }, + { value: '1441', label: '晴隆县' }, + { value: '1442', label: '贞丰县' }, + { value: '1443', label: '安龙县' }, + ], + }, + { + value: '1444', + label: '黔东南苗族侗族自治州', + children: [ + { value: '1460', label: '丹寨县' }, + { value: '1445', label: '凯里市' }, + { value: '1446', label: '施秉县' }, + { value: '1447', label: '从江县' }, + { value: '1448', label: '锦屏县' }, + { value: '1449', label: '镇远县' }, + { value: '1450', label: '麻江县' }, + { value: '1451', label: '台江县' }, + { value: '1452', label: '天柱县' }, + { value: '1453', label: '黄平县' }, + { value: '1454', label: '榕江县' }, + { value: '1455', label: '剑河县' }, + { value: '1456', label: '三穗县' }, + { value: '1457', label: '雷山县' }, + { value: '1458', label: '黎平县' }, + { value: '1459', label: '岑巩县' }, + ], + }, + { + value: '1461', + label: '黔南布依族苗族自治州', + children: [ + { value: '1462', label: '都匀市' }, + { value: '1463', label: '福泉市' }, + { value: '1464', label: '贵定县' }, + { value: '1465', label: '惠水县' }, + { value: '1466', label: '罗甸县' }, + { value: '1467', label: '瓮安县' }, + { value: '1468', label: '荔波县' }, + { value: '1469', label: '龙里县' }, + { value: '1470', label: '平塘县' }, + { value: '1471', label: '长顺县' }, + { value: '1472', label: '独山县' }, + { value: '1473', label: '三都水族自治县' }, + ], + }, + ], + value: '1382', + label: '贵州', + }, + { + children: [ + { + value: '1475', + label: '海口', + children: [ + { value: '1476', label: '海口市' }, + { value: '4409', label: '龙华区' }, + { value: '4613', label: '美兰区' }, + { value: '4614', label: '琼山区' }, + { value: '4615', label: '秀英区' }, + { value: '460109', label: '其它区' }, + ], + }, + { + value: '1477', + label: '三亚', + children: [{ value: '1478', label: '三亚市' }], + }, + { + value: '1479', + label: '五指山', + children: [{ value: '1480', label: '五指山市' }], + }, + { + value: '1481', + label: '琼海', + children: [{ value: '1482', label: '琼海市' }], + }, + { + value: '1483', + label: '儋州', + children: [{ value: '1484', label: '儋州市' }], + }, + { + value: '1485', + label: '文昌', + children: [{ value: '1486', label: '文昌市' }], + }, + { + value: '1487', + label: '万宁', + children: [{ value: '1488', label: '万宁市' }], + }, + { + value: '1489', + label: '东方', + children: [{ value: '1490', label: '东方市' }], + }, + { + value: '1491', + label: '澄迈县', + children: [{ value: '1492', label: '澄迈县' }], + }, + { + value: '1493', + label: '定安县', + children: [{ value: '1494', label: '定安县' }], + }, + { + value: '1495', + label: '屯昌县', + children: [{ value: '1496', label: '屯昌县' }], + }, + { + value: '1497', + label: '临高县', + children: [{ value: '1498', label: '临高县' }], + }, + { + value: '1499', + label: '白沙黎族自治县', + children: [{ value: '1500', label: '白沙黎族自治县' }], + }, + { + value: '1501', + label: '昌江黎族自治县', + children: [{ value: '1502', label: '昌江黎族自治县' }], + }, + { + value: '1503', + label: '乐东黎族自治县', + children: [{ value: '1504', label: '乐东黎族自治县' }], + }, + { + value: '1505', + label: '陵水黎族自治县', + children: [{ value: '1506', label: '陵水黎族自治县' }], + }, + { + value: '1507', + label: '保亭黎族苗族自治县', + children: [{ value: '1508', label: '保亭黎族苗族自治县' }], + }, + { + value: '1509', + label: '琼中黎族苗族自治县', + children: [{ value: '1510', label: '琼中黎族苗族自治县' }], + }, + { + value: '3705', + label: '南沙群岛', + children: [{ value: '3706', label: '南沙群岛' }], + }, + { + value: '3707', + label: '西沙群岛', + children: [{ value: '3708', label: '西沙群岛' }], + }, + { + value: '3709', + label: '中沙群岛的岛礁及其海域', + children: [{ value: '3780', label: '中沙群岛的岛礁及其海域' }], + }, + ], + value: '1474', + label: '海南', + }, + { + children: [ + { + value: '1512', + label: '石家庄', + children: [ + { value: '1523', label: '行唐县' }, + { value: '1513', label: '石家庄市' }, + { value: '1514', label: '辛集市' }, + { value: '1515', label: '藁城市' }, + { value: '1516', label: '晋州市' }, + { value: '1517', label: '新乐市' }, + { value: '1518', label: '鹿泉市' }, + { value: '1519', label: '平山县' }, + { value: '1520', label: '井陉县' }, + { value: '1521', label: '栾城县' }, + { value: '1522', label: '正定县' }, + { value: '1524', label: '灵寿县' }, + { value: '1525', label: '高邑县' }, + { value: '1526', label: '赵县' }, + { value: '1527', label: '赞皇县' }, + { value: '1528', label: '深泽县' }, + { value: '1529', label: '无极县' }, + { value: '1530', label: '元氏县' }, + { value: '4412', label: '桥西区' }, + { value: '4632', label: '长安区' }, + { value: '4633', label: '井陉矿区' }, + { value: '4634', label: '桥东区' }, + { value: '4635', label: '新华区' }, + { value: '4636', label: '裕华区' }, + { value: '130186', label: '其它区' }, + ], + }, + { + value: '1531', + label: '唐山', + children: [ + { value: '1535', label: '迁西县' }, + { value: '1532', label: '唐山市' }, + { value: '1533', label: '遵化市' }, + { value: '1534', label: '迁安市' }, + { value: '1536', label: '滦南县' }, + { value: '1537', label: '玉田县' }, + { value: '1538', label: '唐海县' }, + { value: '1539', label: '乐亭县' }, + { value: '1540', label: '滦县' }, + { value: '4637', label: '丰南区' }, + { value: '4638', label: '丰润区' }, + { value: '4639', label: '古冶区' }, + { value: '4640', label: '开平区' }, + { value: '4641', label: '路北区' }, + { value: '4642', label: '路南区' }, + { value: '130284', label: '其它区' }, + ], + }, + { + value: '1541', + label: '秦皇岛', + children: [ + { value: '1542', label: '秦皇岛市' }, + { value: '1543', label: '昌黎县' }, + { value: '1544', label: '卢龙县' }, + { value: '1545', label: '抚宁县' }, + { value: '1546', label: '青龙满族自治县' }, + { value: '4629', label: '北戴河区' }, + { value: '4630', label: '海港区' }, + { value: '4631', label: '山海关区' }, + { value: '130398', label: '其它区' }, + ], + }, + { + value: '1547', + label: '邯郸', + children: [ + { value: '1548', label: '邯郸市' }, + { value: '1549', label: '武安市' }, + { value: '1550', label: '邯郸县' }, + { value: '1551', label: '永年县' }, + { value: '1552', label: '曲周县' }, + { value: '1553', label: '馆陶县' }, + { value: '1554', label: '魏县' }, + { value: '1555', label: '成安县' }, + { value: '1556', label: '大名县' }, + { value: '1557', label: '涉县' }, + { value: '1558', label: '鸡泽县' }, + { value: '1559', label: '邱县' }, + { value: '1560', label: '广平县' }, + { value: '1561', label: '肥乡县' }, + { value: '1562', label: '临漳县' }, + { value: '1563', label: '磁县' }, + { value: '4623', label: '丛台区' }, + { value: '4624', label: '峰峰矿区' }, + { value: '4625', label: '复兴区' }, + { value: '4626', label: '邯山区' }, + { value: '130482', label: '其它区' }, + ], + }, + { + value: '1564', + label: '邢台', + children: [ + { value: '1574', label: '隆尧县' }, + { value: '1565', label: '邢台市' }, + { value: '1566', label: '南宫市' }, + { value: '1567', label: '沙河市' }, + { value: '1568', label: '邢台县' }, + { value: '1569', label: '柏乡县' }, + { value: '1570', label: '任县' }, + { value: '1571', label: '清河县' }, + { value: '1572', label: '宁晋县' }, + { value: '1573', label: '威县' }, + { value: '1575', label: '临城县' }, + { value: '1576', label: '广宗县' }, + { value: '1577', label: '临西县' }, + { value: '1578', label: '内丘县' }, + { value: '1579', label: '平乡县' }, + { value: '1580', label: '巨鹿县' }, + { value: '1581', label: '新河县' }, + { value: '1582', label: '南和县' }, + { value: '4643', label: '桥东区' }, + { value: '4644', label: '桥西区' }, + { value: '130583', label: '其它区' }, + ], + }, + { + value: '1583', + label: '保定', + children: [ + { value: '1587', label: '安国市' }, + { value: '1599', label: '望都县' }, + { value: '1584', label: '保定市' }, + { value: '1585', label: '涿州市' }, + { value: '1586', label: '定州市' }, + { value: '1588', label: '高碑店市' }, + { value: '1589', label: '满城县' }, + { value: '1590', label: '清苑县' }, + { value: '1591', label: '涞水县' }, + { value: '1592', label: '阜平县' }, + { value: '1593', label: '徐水县' }, + { value: '1594', label: '定兴县' }, + { value: '1595', label: '唐县' }, + { value: '1596', label: '高阳县' }, + { value: '1597', label: '容城县' }, + { value: '1598', label: '涞源县' }, + { value: '1600', label: '安新县' }, + { value: '1601', label: '易县' }, + { value: '1602', label: '曲阳县' }, + { value: '1603', label: '蠡县' }, + { value: '1604', label: '顺平县' }, + { value: '1605', label: '博野县' }, + { value: '1606', label: '雄县' }, + { value: '4616', label: '北市区' }, + { value: '4617', label: '南市区' }, + { value: '4618', label: '新市区' }, + { value: '130698', label: '高开区' }, + { value: '130699', label: '其它区' }, + ], + }, + { + value: '1607', + label: '张家口', + children: [ + { value: '1608', label: '张家口市' }, + { value: '1609', label: '宣化县' }, + { value: '1610', label: '康保县' }, + { value: '1611', label: '张北县' }, + { value: '1612', label: '阳原县' }, + { value: '1613', label: '赤城县' }, + { value: '1614', label: '沽源县' }, + { value: '1615', label: '怀安县' }, + { value: '1616', label: '怀来县' }, + { value: '1617', label: '崇礼县' }, + { value: '1618', label: '尚义县' }, + { value: '1619', label: '蔚县' }, + { value: '1620', label: '涿鹿县' }, + { value: '1621', label: '万全县' }, + { value: '4645', label: '桥东区' }, + { value: '4646', label: '桥西区' }, + { value: '4647', label: '下花园区' }, + { value: '4648', label: '宣化区' }, + { value: '130734', label: '其它区' }, + ], + }, + { + value: '1622', + label: '承德', + children: [ + { value: '1623', label: '承德市' }, + { value: '1624', label: '承德县' }, + { value: '1625', label: '兴隆县' }, + { value: '1626', label: '隆化县' }, + { value: '1627', label: '平泉县' }, + { value: '1628', label: '滦平县' }, + { value: '1629', label: '丰宁满族自治县' }, + { value: '1630', label: '围场满族蒙古族自治县' }, + { value: '1631', label: '宽城满族自治县' }, + { value: '4410', label: '双滦区' }, + { value: '4621', label: '双桥区' }, + { value: '4622', label: '鹰手营子矿区' }, + { value: '130829', label: '其它区' }, + ], + }, + { + value: '1632', + label: '沧州', + children: [ + { value: '1646', label: '吴桥县' }, + { value: '1633', label: '沧州市' }, + { value: '1634', label: '泊头市' }, + { value: '1635', label: '任丘市' }, + { value: '1636', label: '黄骅市' }, + { value: '1637', label: '河间市' }, + { value: '1638', label: '沧县' }, + { value: '1639', label: '青县' }, + { value: '1640', label: '献县' }, + { value: '1641', label: '东光县' }, + { value: '1642', label: '海兴县' }, + { value: '1643', label: '盐山县' }, + { value: '1644', label: '肃宁县' }, + { value: '1645', label: '南皮县' }, + { value: '1647', label: '孟村回族自治县' }, + { value: '4619', label: '新华区' }, + { value: '4620', label: '运河区' }, + { value: '130985', label: '其它区' }, + ], + }, + { + value: '1648', + label: '廊坊', + children: [ + { value: '1649', label: '廊坊市' }, + { value: '1650', label: '霸州市' }, + { value: '1651', label: '三河市' }, + { value: '1652', label: '固安县' }, + { value: '1653', label: '永清县' }, + { value: '1654', label: '香河县' }, + { value: '1655', label: '大城县' }, + { value: '1656', label: '文安县' }, + { value: '1657', label: '大厂回族自治县' }, + { value: '4411', label: '安次区' }, + { value: '4628', label: '广阳区' }, + { value: '131052', label: '燕郊经济技术开发区' }, + { value: '131083', label: '其它区' }, + ], + }, + { + value: '1658', + label: '衡水', + children: [ + { value: '1659', label: '衡水市' }, + { value: '1660', label: '冀州市' }, + { value: '1661', label: '深州市' }, + { value: '1662', label: '饶阳县' }, + { value: '1663', label: '枣强县' }, + { value: '1664', label: '故城县' }, + { value: '1665', label: '阜城县' }, + { value: '1666', label: '安平县' }, + { value: '1667', label: '武邑县' }, + { value: '1668', label: '景县' }, + { value: '1669', label: '武强县' }, + { value: '4627', label: '桃城区' }, + { value: '131183', label: '其它区' }, + ], + }, + ], + value: '1511', + label: '河北', + }, + { + children: [ + { + value: '1671', + label: '郑州', + children: [ + { value: '1672', label: '郑州市' }, + { value: '1673', label: '巩义市' }, + { value: '1674', label: '新郑市' }, + { value: '1675', label: '新密市' }, + { value: '1676', label: '登封市' }, + { value: '1677', label: '荥阳市' }, + { value: '1678', label: '中牟县' }, + { value: '4689', label: '二七区' }, + { value: '4690', label: '管城回族区' }, + { value: '4691', label: '惠济区' }, + { value: '4692', label: '金水区' }, + { value: '4693', label: '上街区' }, + { value: '4694', label: '中原区' }, + { value: '10006', label: '郑东新区' }, + { value: '10007', label: '郑州矿区' }, + { value: '10008', label: '高新技术产业开发区' }, + { value: '10009', label: '经济技术开发区' }, + { value: '10010', label: '出口加工区' }, + { value: '410188', label: '其它区' }, + ], + }, + { + value: '1679', + label: '开封', + children: [ + { value: '1680', label: '开封市' }, + { value: '1681', label: '开封县' }, + { value: '1682', label: '尉氏县' }, + { value: '1683', label: '兰考县' }, + { value: '1684', label: '杞县' }, + { value: '1685', label: '通许县' }, + { value: '4660', label: '鼓楼区' }, + { value: '4661', label: '金明区' }, + { value: '4662', label: '龙亭区' }, + { value: '4663', label: '顺河回族区' }, + { value: '4664', label: '禹王台区' }, + { value: '410226', label: '其它区' }, + ], + }, + { + value: '1686', + label: '洛阳', + children: [ + { value: '1687', label: '洛阳市' }, + { value: '1688', label: '偃师市' }, + { value: '1689', label: '孟津县' }, + { value: '1690', label: '汝阳县' }, + { value: '1691', label: '伊川县' }, + { value: '1692', label: '洛宁县' }, + { value: '1693', label: '嵩县' }, + { value: '1694', label: '宜阳县' }, + { value: '1695', label: '新安县' }, + { value: '1696', label: '栾川县' }, + { value: '4665', label: '廛河回族区' }, + { value: '4666', label: '吉利区' }, + { value: '4667', label: '涧西区' }, + { value: '4668', label: '老城区' }, + { value: '4669', label: '洛龙区' }, + { value: '4670', label: '西工区' }, + { value: '10001', label: '高新区' }, + { value: '471005', label: '其它区' }, + ], + }, + { + value: '1697', + label: '平顶山', + children: [ + { value: '1698', label: '平顶山市' }, + { value: '1699', label: '汝州市' }, + { value: '1700', label: '舞钢市' }, + { value: '1701', label: '宝丰县' }, + { value: '1702', label: '叶县' }, + { value: '1703', label: '郏县' }, + { value: '1704', label: '鲁山县' }, + { value: '4675', label: '石龙区' }, + { value: '4676', label: '卫东区' }, + { value: '4677', label: '新华区' }, + { value: '4678', label: '湛河区' }, + { value: '410483', label: '其它区' }, + ], + }, + { + value: '1705', + label: '焦作', + children: [ + { value: '1706', label: '焦作市' }, + { value: '1707', label: '沁阳市' }, + { value: '1708', label: '孟州市' }, + { value: '1709', label: '修武县' }, + { value: '1710', label: '温县' }, + { value: '1711', label: '武陟县' }, + { value: '1712', label: '博爱县' }, + { value: '1815', label: '济源市' }, + { value: '4656', label: '解放区' }, + { value: '4657', label: '马村区' }, + { value: '4658', label: '山阳区' }, + { value: '4659', label: '中站区' }, + { value: '410884', label: '其它区' }, + ], + }, + { + value: '1713', + label: '鹤壁', + children: [ + { value: '1714', label: '鹤壁市' }, + { value: '1715', label: '浚县' }, + { value: '1716', label: '淇县' }, + { value: '4653', label: '鹤山区' }, + { value: '4654', label: '淇滨区' }, + { value: '4655', label: '山城区' }, + { value: '410623', label: '其它区' }, + ], + }, + { + value: '1717', + label: '新乡', + children: [ + { value: '1725', label: '封丘县' }, + { value: '1718', label: '新乡市' }, + { value: '1719', label: '卫辉市' }, + { value: '1720', label: '辉县市' }, + { value: '1721', label: '新乡县' }, + { value: '1722', label: '获嘉县' }, + { value: '1723', label: '原阳县' }, + { value: '1724', label: '长垣县' }, + { value: '1726', label: '延津县' }, + { value: '4683', label: '凤泉区' }, + { value: '4684', label: '红旗区' }, + { value: '4685', label: '牧野区' }, + { value: '4686', label: '卫滨区' }, + { value: '10000', label: '高新区' }, + { value: '410783', label: '其它区' }, + ], + }, + { + value: '1727', + label: '安阳', + children: [ + { value: '1728', label: '安阳市' }, + { value: '1729', label: '林州市' }, + { value: '1730', label: '安阳县' }, + { value: '1731', label: '滑县' }, + { value: '1732', label: '内黄县' }, + { value: '1733', label: '汤阴县' }, + { value: '4649', label: '北关区' }, + { value: '4650', label: '龙安区' }, + { value: '4651', label: '文峰区' }, + { value: '4652', label: '殷都区' }, + { value: '410582', label: '其它区' }, + ], + }, + { + value: '1734', + label: '濮阳', + children: [ + { value: '1737', label: '南乐县' }, + { value: '1735', label: '濮阳市' }, + { value: '1736', label: '濮阳县' }, + { value: '1738', label: '台前县' }, + { value: '1739', label: '清丰县' }, + { value: '1740', label: '范县' }, + { value: '4679', label: '华龙区' }, + { value: '410929', label: '其它区' }, + ], + }, + { + value: '1741', + label: '许昌', + children: [ + { value: '1742', label: '许昌市' }, + { value: '1743', label: '禹州市' }, + { value: '1744', label: '长葛市' }, + { value: '1745', label: '许昌县' }, + { value: '1746', label: '鄢陵县' }, + { value: '1747', label: '襄城县' }, + { value: '4413', label: '魏都区' }, + { value: '411083', label: '其它区' }, + ], + }, + { + value: '1748', + label: '漯河', + children: [ + { value: '1750', label: '郾城区' }, + { value: '1749', label: '漯河市' }, + { value: '1751', label: '临颍县' }, + { value: '1752', label: '舞阳县' }, + { value: '4671', label: '源汇区' }, + { value: '4672', label: '召陵区' }, + { value: '411123', label: '其它区' }, + ], + }, + { + value: '1753', + label: '三门峡', + children: [ + { value: '1754', label: '三门峡市' }, + { value: '1755', label: '义马市' }, + { value: '1756', label: '灵宝市' }, + { value: '1757', label: '渑池县' }, + { value: '1758', label: '卢氏县' }, + { value: '1759', label: '陕县' }, + { value: '4680', label: '湖滨区' }, + { value: '411283', label: '其它区' }, + ], + }, + { + value: '1760', + label: '南阳', + children: [ + { value: '1761', label: '南阳市' }, + { value: '1762', label: '邓州市' }, + { value: '1763', label: '桐柏县' }, + { value: '1764', label: '方城县' }, + { value: '1765', label: '淅川县' }, + { value: '1766', label: '镇平县' }, + { value: '1767', label: '唐河县' }, + { value: '1768', label: '南召县' }, + { value: '1769', label: '内乡县' }, + { value: '1770', label: '新野县' }, + { value: '1771', label: '社旗县' }, + { value: '1772', label: '西峡县' }, + { value: '4673', label: '宛城区' }, + { value: '4674', label: '卧龙区' }, + { value: '411382', label: '其它区' }, + ], + }, + { + value: '1773', + label: '商丘', + children: [ + { value: '1776', label: '宁陵县' }, + { value: '1774', label: '商丘市' }, + { value: '1775', label: '永城市' }, + { value: '1777', label: '虞城县' }, + { value: '1778', label: '民权县' }, + { value: '1779', label: '夏邑县' }, + { value: '1780', label: '柘城县' }, + { value: '1781', label: '睢县' }, + { value: '4681', label: '梁园区' }, + { value: '4682', label: '睢阳区' }, + { value: '411482', label: '其它区' }, + ], + }, + { + value: '1782', + label: '信阳', + children: [ + { value: '1788', label: '商城县' }, + { value: '1783', label: '信阳市' }, + { value: '1784', label: '潢川县' }, + { value: '1785', label: '淮滨县' }, + { value: '1786', label: '息县' }, + { value: '1787', label: '新县' }, + { value: '1789', label: '固始县' }, + { value: '1790', label: '罗山县' }, + { value: '1791', label: '光山县' }, + { value: '4687', label: '平桥区' }, + { value: '4688', label: '浉河区' }, + { value: '411529', label: '其它区' }, + ], + }, + { + value: '1792', + label: '周口', + children: [ + { value: '1801', label: '沈丘县' }, + { value: '1793', label: '周口市' }, + { value: '1794', label: '项城市' }, + { value: '1795', label: '商水县' }, + { value: '1796', label: '淮阳县' }, + { value: '1797', label: '太康县' }, + { value: '1798', label: '鹿邑县' }, + { value: '1799', label: '西华县' }, + { value: '1800', label: '扶沟县' }, + { value: '1802', label: '郸城县' }, + { value: '4695', label: '川汇区' }, + { value: '411682', label: '其它区' }, + ], + }, + { + value: '1803', + label: '驻马店', + children: [ + { value: '1804', label: '驻马店市' }, + { value: '1805', label: '确山县' }, + { value: '1806', label: '新蔡县' }, + { value: '1807', label: '上蔡县' }, + { value: '1808', label: '西平县' }, + { value: '1809', label: '泌阳县' }, + { value: '1810', label: '平舆县' }, + { value: '1811', label: '汝南县' }, + { value: '1812', label: '遂平县' }, + { value: '1813', label: '正阳县' }, + { value: '4414', label: '驿城区' }, + { value: '411730', label: '其它区' }, + ], + }, + ], + value: '1670', + label: '河南', + }, + { + children: [ + { + value: '1817', + label: '哈尔滨', + children: [ + { value: '1819', label: '阿城区' }, + { value: '1823', label: '呼兰区' }, + { value: '1818', label: '哈尔滨市' }, + { value: '1820', label: '尚志市' }, + { value: '1821', label: '双城市' }, + { value: '1822', label: '五常市' }, + { value: '1824', label: '方正县' }, + { value: '1825', label: '宾县' }, + { value: '1826', label: '依兰县' }, + { value: '1827', label: '巴彦县' }, + { value: '1828', label: '通河县' }, + { value: '1829', label: '木兰县' }, + { value: '1830', label: '延寿县' }, + { value: '4415', label: '南岗区' }, + { value: '4704', label: '道里区' }, + { value: '4705', label: '道外区' }, + { value: '4706', label: '平房区' }, + { value: '4707', label: '松北区' }, + { value: '4708', label: '香坊区' }, + { value: '230107', label: '动力区' }, + { value: '230181', label: '阿城市' }, + { value: '230185', label: '阿城市' }, + { value: '230186', label: '其它区' }, + ], + }, + { + value: '1831', + label: '齐齐哈尔', + children: [ + { value: '1832', label: '齐齐哈尔市' }, + { value: '1833', label: '讷河市' }, + { value: '1834', label: '富裕县' }, + { value: '1835', label: '拜泉县' }, + { value: '1836', label: '甘南县' }, + { value: '1837', label: '依安县' }, + { value: '1838', label: '克山县' }, + { value: '1839', label: '泰来县' }, + { value: '1840', label: '克东县' }, + { value: '1841', label: '龙江县' }, + { value: '4418', label: '富拉尔基区' }, + { value: '4731', label: '昂昂溪区' }, + { value: '4732', label: '建华区' }, + { value: '4733', label: '龙沙区' }, + { value: '4734', label: '梅里斯达斡尔族区' }, + { value: '4735', label: '碾子山区' }, + { value: '4736', label: '铁锋区' }, + { value: '230282', label: '其它区' }, + ], + }, + { + value: '1842', + label: '鹤岗', + children: [ + { value: '1843', label: '鹤岗市' }, + { value: '1844', label: '萝北县' }, + { value: '1845', label: '绥滨县' }, + { value: '4709', label: '东山区' }, + { value: '4710', label: '工农区' }, + { value: '4711', label: '南山区' }, + { value: '4712', label: '向阳区' }, + { value: '4713', label: '兴安区' }, + { value: '4714', label: '兴山区' }, + { value: '230423', label: '其它区' }, + ], + }, + { + value: '1846', + label: '双鸭山', + children: [ + { value: '1849', label: '宝清县' }, + { value: '1847', label: '双鸭山市' }, + { value: '1848', label: '集贤县' }, + { value: '1850', label: '友谊县' }, + { value: '1851', label: '饶河县' }, + { value: '4737', label: '宝山区' }, + { value: '4738', label: '尖山区' }, + { value: '4739', label: '岭东区' }, + { value: '4740', label: '四方台区' }, + { value: '230525', label: '其它区' }, + ], + }, + { + value: '1852', + label: '鸡西', + children: [ + { value: '1853', label: '鸡西市' }, + { value: '1854', label: '密山市' }, + { value: '1855', label: '虎林市' }, + { value: '1856', label: '鸡东县' }, + { value: '4715', label: '城子河区' }, + { value: '4716', label: '滴道区' }, + { value: '4717', label: '恒山区' }, + { value: '4718', label: '鸡冠区' }, + { value: '4719', label: '梨树区' }, + { value: '4720', label: '麻山区' }, + { value: '230383', label: '其它区' }, + ], + }, + { + value: '1857', + label: '大庆', + children: [ + { value: '1861', label: '肇源县' }, + { value: '1858', label: '大庆市' }, + { value: '1859', label: '林甸县' }, + { value: '1860', label: '肇州县' }, + { value: '1862', label: '杜尔伯特蒙古族自治县' }, + { value: '4696', label: '大同区' }, + { value: '4697', label: '红岗区' }, + { value: '4698', label: '龙凤区' }, + { value: '4699', label: '让胡路区' }, + { value: '4700', label: '萨尔图区' }, + { value: '230625', label: '其它区' }, + ], + }, + { + value: '1863', + label: '伊春', + children: [ + { value: '1864', label: '伊春市' }, + { value: '1865', label: '铁力市' }, + { value: '1866', label: '嘉荫县' }, + { value: '4419', label: '红星区' }, + { value: '4420', label: '西林区' }, + { value: '4742', label: '翠峦区' }, + { value: '4743', label: '带岭区' }, + { value: '4744', label: '金山屯区' }, + { value: '4745', label: '美溪区' }, + { value: '4746', label: '南岔区' }, + { value: '4747', label: '上甘岭区' }, + { value: '4748', label: '汤旺河区' }, + { value: '4749', label: '乌马河区' }, + { value: '4750', label: '乌伊岭区' }, + { value: '4751', label: '五营区' }, + { value: '4752', label: '新青区' }, + { value: '4753', label: '伊春区' }, + { value: '4754', label: '友好区' }, + { value: '230782', label: '其它区' }, + ], + }, + { + value: '1867', + label: '牡丹江', + children: [ + { value: '1874', label: '东宁县' }, + { value: '1868', label: '牡丹江市' }, + { value: '1869', label: '绥芬河市' }, + { value: '1870', label: '宁安市' }, + { value: '1871', label: '海林市' }, + { value: '1872', label: '穆棱市' }, + { value: '1873', label: '林口县' }, + { value: '4724', label: '爱民区' }, + { value: '4725', label: '东安区' }, + { value: '4726', label: '西安区' }, + { value: '4727', label: '阳明区' }, + { value: '231086', label: '其它区' }, + ], + }, + { + value: '1875', + label: '佳木斯', + children: [ + { value: '1876', label: '佳木斯市' }, + { value: '1877', label: '同江市' }, + { value: '1878', label: '富锦市' }, + { value: '1879', label: '桦川县' }, + { value: '1880', label: '抚远县' }, + { value: '1881', label: '桦南县' }, + { value: '1882', label: '汤原县' }, + { value: '4417', label: '前进区' }, + { value: '4721', label: '东风区' }, + { value: '4722', label: '郊区' }, + { value: '4723', label: '向阳区' }, + { value: '230802', label: '永红区' }, + { value: '230883', label: '其它区' }, + ], + }, + { + value: '1883', + label: '七台河', + children: [ + { value: '1884', label: '七台河市' }, + { value: '1885', label: '勃利县' }, + { value: '4728', label: '茄子河区' }, + { value: '4729', label: '桃山区' }, + { value: '4730', label: '新兴区' }, + { value: '230922', label: '其它区' }, + ], + }, + { + value: '1886', + label: '黑河', + children: [ + { value: '1887', label: '黑河市' }, + { value: '1888', label: '北安市' }, + { value: '1889', label: '五大连池市' }, + { value: '1890', label: '逊克县' }, + { value: '1891', label: '嫩江县' }, + { value: '1892', label: '孙吴县' }, + { value: '4416', label: '爱辉区' }, + { value: '231183', label: '其它区' }, + ], + }, + { + value: '1893', + label: '绥化', + children: [ + { value: '1894', label: '绥化市' }, + { value: '1895', label: '安达市' }, + { value: '1896', label: '肇东市' }, + { value: '1897', label: '海伦市' }, + { value: '1898', label: '绥棱县' }, + { value: '1899', label: '兰西县' }, + { value: '1900', label: '明水县' }, + { value: '1901', label: '青冈县' }, + { value: '1902', label: '庆安县' }, + { value: '1903', label: '望奎县' }, + { value: '4741', label: '北林区' }, + { value: '231284', label: '其它区' }, + ], + }, + { + value: '1904', + label: '大兴安岭', + children: [ + { value: '3704', label: '大兴安岭市' }, + { value: '1905', label: '呼玛县' }, + { value: '1906', label: '塔河县' }, + { value: '1907', label: '漠河县' }, + { value: '3703', label: '加格达奇区' }, + { value: '4701', label: '呼中区' }, + { value: '4702', label: '松岭区' }, + { value: '4703', label: '新林区' }, + ], + }, + ], + value: '1816', + label: '黑龙江', + }, + { + children: [ + { + value: '2003', + label: '长沙', + children: [ + { value: '2008', label: '宁乡县' }, + { value: '2004', label: '长沙市' }, + { value: '2005', label: '浏阳市' }, + { value: '2006', label: '长沙县' }, + { value: '2007', label: '望城县' }, + { value: '4425', label: '芙蓉区' }, + { value: '4790', label: '开福区' }, + { value: '4791', label: '天心区' }, + { value: '4792', label: '雨花区' }, + { value: '4793', label: '岳麓区' }, + { value: '430182', label: '其它区' }, + ], + }, + { + value: '2009', + label: '株洲', + children: [ + { value: '2010', label: '株洲市' }, + { value: '2011', label: '醴陵市' }, + { value: '2012', label: '株洲县' }, + { value: '2013', label: '炎陵县' }, + { value: '2014', label: '茶陵县' }, + { value: '2015', label: '攸县' }, + { value: '4429', label: '天元区' }, + { value: '4816', label: '荷塘区' }, + { value: '4817', label: '芦淞区' }, + { value: '4818', label: '石峰区' }, + { value: '430282', label: '其它区' }, + ], + }, + { + value: '2016', + label: '湘潭', + children: [ + { value: '2017', label: '湘潭市' }, + { value: '2018', label: '湘乡市' }, + { value: '2019', label: '韶山市' }, + { value: '2020', label: '湘潭县' }, + { value: '4806', label: '雨湖区' }, + { value: '4807', label: '岳塘区' }, + { value: '430383', label: '其它区' }, + ], + }, + { + value: '2021', + label: '衡阳', + children: [ + { value: '2022', label: '衡阳市' }, + { value: '2023', label: '耒阳市' }, + { value: '2024', label: '常宁市' }, + { value: '2025', label: '衡阳县' }, + { value: '2026', label: '衡东县' }, + { value: '2027', label: '衡山县' }, + { value: '2028', label: '衡南县' }, + { value: '2029', label: '祁东县' }, + { value: '4797', label: '南岳区' }, + { value: '4798', label: '石鼓区' }, + { value: '4799', label: '雁峰区' }, + { value: '4800', label: '蒸湘区' }, + { value: '4801', label: '珠晖区' }, + { value: '430483', label: '其它区' }, + ], + }, + { + value: '2030', + label: '邵阳', + children: [ + { value: '2031', label: '邵阳市' }, + { value: '2032', label: '武冈市' }, + { value: '2033', label: '邵东县' }, + { value: '2034', label: '洞口县' }, + { value: '2035', label: '新邵县' }, + { value: '2036', label: '绥宁县' }, + { value: '2037', label: '新宁县' }, + { value: '2038', label: '邵阳县' }, + { value: '2039', label: '隆回县' }, + { value: '2040', label: '城步苗族自治县' }, + { value: '4427', label: '北塔区' }, + { value: '4804', label: '大祥区' }, + { value: '4805', label: '双清区' }, + { value: '430582', label: '其它区' }, + ], + }, + { + value: '2041', + label: '岳阳', + children: [ + { value: '2044', label: '汨罗市' }, + { value: '2042', label: '岳阳市' }, + { value: '2043', label: '临湘市' }, + { value: '2045', label: '岳阳县' }, + { value: '2046', label: '湘阴县' }, + { value: '2047', label: '平江县' }, + { value: '2048', label: '华容县' }, + { value: '4428', label: '君山区' }, + { value: '4812', label: '岳阳楼区' }, + { value: '4813', label: '云溪区' }, + { value: '430683', label: '其它区' }, + ], + }, + { + value: '2049', + label: '常德', + children: [ + { value: '2056', label: '安乡县' }, + { value: '2050', label: '常德市' }, + { value: '2051', label: '津市市' }, + { value: '2052', label: '澧县' }, + { value: '2053', label: '临澧县' }, + { value: '2054', label: '桃源县' }, + { value: '2055', label: '汉寿县' }, + { value: '2057', label: '石门县' }, + { value: '4794', label: '鼎城区' }, + { value: '4795', label: '武陵区' }, + { value: '430782', label: '其它区' }, + ], + }, + { + value: '2058', + label: '张家界', + children: [ + { value: '2059', label: '张家界市' }, + { value: '2060', label: '慈利县' }, + { value: '2061', label: '桑植县' }, + { value: '4814', label: '武陵源区' }, + { value: '4815', label: '永定区' }, + { value: '430823', label: '其它区' }, + ], + }, + { + value: '2062', + label: '益阳', + children: [ + { value: '2063', label: '益阳市' }, + { value: '2064', label: '沅江市' }, + { value: '2065', label: '桃江县' }, + { value: '2066', label: '南县' }, + { value: '2067', label: '安化县' }, + { value: '4808', label: '赫山区' }, + { value: '4809', label: '资阳区' }, + { value: '430982', label: '其它区' }, + ], + }, + { + value: '2068', + label: '郴州', + children: [ + { value: '2069', label: '郴州市' }, + { value: '2070', label: '资兴市' }, + { value: '2071', label: '宜章县' }, + { value: '2072', label: '汝城县' }, + { value: '2073', label: '安仁县' }, + { value: '2074', label: '嘉禾县' }, + { value: '2075', label: '临武县' }, + { value: '2076', label: '桂东县' }, + { value: '2077', label: '永兴县' }, + { value: '2078', label: '桂阳县' }, + { value: '4426', label: '苏仙区' }, + { value: '4796', label: '北湖区' }, + { value: '431082', label: '其它区' }, + ], + }, + { + value: '2079', + label: '永州', + children: [ + { value: '2080', label: '永州市' }, + { value: '2081', label: '祁阳县' }, + { value: '2082', label: '蓝山县' }, + { value: '2083', label: '宁远县' }, + { value: '2084', label: '新田县' }, + { value: '2085', label: '东安县' }, + { value: '2086', label: '江永县' }, + { value: '2087', label: '道县' }, + { value: '2088', label: '双牌县' }, + { value: '2089', label: '江华瑶族自治县' }, + { value: '4810', label: '冷水滩区' }, + { value: '4811', label: '零陵区' }, + { value: '431130', label: '其它区' }, + ], + }, + { + value: '2090', + label: '怀化', + children: [ + { value: '2091', label: '怀化市' }, + { value: '2092', label: '洪江市' }, + { value: '2093', label: '会同县' }, + { value: '2094', label: '沅陵县' }, + { value: '2095', label: '辰溪县' }, + { value: '2096', label: '溆浦县' }, + { value: '2097', label: '中方县' }, + { value: '2098', label: '新晃侗族自治县' }, + { value: '2099', label: '芷江侗族自治县' }, + { value: '2100', label: '通道侗族自治县' }, + { value: '2101', label: '靖州苗族侗族自治县' }, + { value: '2102', label: '麻阳苗族自治县' }, + { value: '4802', label: '鹤城区' }, + { value: '431282', label: '其它区' }, + ], + }, + { + value: '2103', + label: '娄底', + children: [ + { value: '2104', label: '娄底市' }, + { value: '2105', label: '冷水江市' }, + { value: '2106', label: '涟源市' }, + { value: '2107', label: '新化县' }, + { value: '2108', label: '双峰县' }, + { value: '4803', label: '娄星区' }, + { value: '431383', label: '其它区' }, + ], + }, + { + value: '2109', + label: '湘西土家族苗族自治州', + children: [ + { value: '2110', label: '吉首市' }, + { value: '2111', label: '古丈县' }, + { value: '2112', label: '龙山县' }, + { value: '2113', label: '永顺县' }, + { value: '2114', label: '凤凰县' }, + { value: '2115', label: '泸溪县' }, + { value: '2116', label: '保靖县' }, + { value: '2117', label: '花垣县' }, + ], + }, + ], + value: '2002', + label: '湖南', + }, + { + children: [ + { + value: '2119', + label: '长春', + children: [ + { value: '2123', label: '德惠市' }, + { value: '2120', label: '长春市' }, + { value: '2121', label: '九台市' }, + { value: '2122', label: '榆树市' }, + { value: '2124', label: '农安县' }, + { value: '4430', label: '南关区' }, + { value: '4821', label: '朝阳区' }, + { value: '4822', label: '二道区' }, + { value: '4823', label: '宽城区' }, + { value: '4824', label: '绿园区' }, + { value: '4825', label: '双阳区' }, + { value: '220185', label: '汽车产业开发区' }, + { value: '220187', label: '净月旅游开发区' }, + { value: '220188', label: '其它区' }, + ], + }, + { + value: '2125', + label: '吉林', + children: [ + { value: '2126', label: '吉林市' }, + { value: '2127', label: '舒兰市' }, + { value: '2128', label: '桦甸市' }, + { value: '2129', label: '蛟河市' }, + { value: '2130', label: '磐石市' }, + { value: '2131', label: '永吉县' }, + { value: '4826', label: '昌邑区' }, + { value: '4827', label: '船营区' }, + { value: '4828', label: '丰满区' }, + { value: '4829', label: '龙潭区' }, + { value: '220285', label: '其它区' }, + ], + }, + { + value: '2132', + label: '四平', + children: [ + { value: '2135', label: '双辽市' }, + { value: '2133', label: '四平市' }, + { value: '2134', label: '公主岭市' }, + { value: '2136', label: '梨树县' }, + { value: '2137', label: '伊通满族自治县' }, + { value: '4431', label: '铁西区' }, + { value: '4832', label: '铁东区' }, + { value: '220383', label: '其它区' }, + ], + }, + { + value: '2138', + label: '辽源', + children: [ + { value: '2139', label: '辽源市' }, + { value: '2140', label: '东辽县' }, + { value: '2141', label: '东丰县' }, + { value: '4830', label: '龙山区' }, + { value: '4831', label: '西安区' }, + { value: '220423', label: '其它区' }, + ], + }, + { + value: '2142', + label: '通化', + children: [ + { value: '2143', label: '通化市' }, + { value: '2144', label: '梅河口市' }, + { value: '2145', label: '集安市' }, + { value: '2146', label: '通化县' }, + { value: '2147', label: '辉南县' }, + { value: '2148', label: '柳河县' }, + { value: '4834', label: '东昌区' }, + { value: '4835', label: '二道江区' }, + { value: '220583', label: '其它区' }, + ], + }, + { + value: '2149', + label: '白山', + children: [ + { value: '2154', label: '江源区' }, + { value: '2150', label: '白山市' }, + { value: '2151', label: '临江市' }, + { value: '2152', label: '靖宇县' }, + { value: '2153', label: '抚松县' }, + { value: '2155', label: '长白朝鲜族自治县' }, + { value: '4820', label: '八道江区' }, + { value: '220625', label: '江源县' }, + { value: '220682', label: '其它区' }, + ], + }, + { + value: '2156', + label: '松原', + children: [ + { value: '2157', label: '松原市' }, + { value: '2158', label: '乾安县' }, + { value: '2159', label: '长岭县' }, + { value: '2160', label: '扶余县' }, + { value: '2161', label: '前郭尔罗斯蒙古族自治县' }, + { value: '4833', label: '宁江区' }, + { value: '220725', label: '其它区' }, + ], + }, + { + value: '2162', + label: '白城', + children: [ + { value: '2163', label: '白城市' }, + { value: '2164', label: '大安市' }, + { value: '2165', label: '洮南市' }, + { value: '2166', label: '镇赉县' }, + { value: '2167', label: '通榆县' }, + { value: '4819', label: '洮北区' }, + { value: '220883', label: '其它区' }, + ], + }, + { + value: '2168', + label: '延边朝鲜族自治州', + children: [ + { value: '2169', label: '延吉市' }, + { value: '2170', label: '图们市' }, + { value: '2171', label: '敦化市' }, + { value: '2172', label: '龙井市' }, + { value: '2173', label: '珲春市' }, + { value: '2174', label: '和龙市' }, + { value: '2175', label: '安图县' }, + { value: '2176', label: '汪清县' }, + ], + }, + ], + value: '2118', + label: '吉林', + }, + { + children: [ + { + value: '2178', + label: '南京', + children: [ + { value: '2179', label: '南京市' }, + { value: '2180', label: '溧水县' }, + { value: '2181', label: '高淳县' }, + { value: '4002', label: '白下区' }, + { value: '4003', label: '鼓楼区' }, + { value: '4004', label: '建邺区' }, + { value: '4005', label: '江宁区' }, + { value: '4006', label: '六合区' }, + { value: '4007', label: '浦口区' }, + { value: '4008', label: '栖霞区' }, + { value: '4009', label: '秦淮区' }, + { value: '4010', label: '下关区' }, + { value: '4011', label: '玄武区' }, + { value: '4012', label: '雨花台区' }, + { value: '320126', label: '其它区' }, + ], + }, + { + value: '2182', + label: '徐州', + children: [ + { value: '2185', label: '新沂市' }, + { value: '2183', label: '徐州市' }, + { value: '2184', label: '邳州市' }, + { value: '2186', label: '铜山县' }, + { value: '2187', label: '睢宁县' }, + { value: '2188', label: '沛县' }, + { value: '2189', label: '丰县' }, + { value: '4030', label: '鼓楼区' }, + { value: '4031', label: '贾汪区' }, + { value: '4032', label: '泉山区' }, + { value: '4033', label: '云龙区' }, + { value: '4366', label: '九里区' }, + { value: '320383', label: '其它区' }, + ], + }, + { + value: '2190', + label: '连云港', + children: [ + { value: '2191', label: '连云港市' }, + { value: '2192', label: '东海县' }, + { value: '2193', label: '灌云县' }, + { value: '2194', label: '赣榆县' }, + { value: '2195', label: '灌南县' }, + { value: '4000', label: '连云区' }, + { value: '4001', label: '新浦区' }, + { value: '4844', label: '海州区' }, + { value: '320725', label: '其它区' }, + ], + }, + { + value: '2196', + label: '淮安', + children: [ + { value: '2198', label: '涟水县' }, + { value: '2197', label: '淮安市' }, + { value: '2199', label: '洪泽县' }, + { value: '2200', label: '金湖县' }, + { value: '2201', label: '盱眙县' }, + { value: '4840', label: '楚州区' }, + { value: '4841', label: '淮阴区' }, + { value: '4842', label: '清河区' }, + { value: '4843', label: '清浦区' }, + { value: '320832', label: '其它区' }, + ], + }, + { + value: '2202', + label: '宿迁', + children: [ + { value: '2204', label: '宿豫区' }, + { value: '2203', label: '宿迁市' }, + { value: '2205', label: '沭阳县' }, + { value: '2206', label: '泗阳县' }, + { value: '2207', label: '泗洪县' }, + { value: '4021', label: '宿城区' }, + { value: '321325', label: '其它区' }, + ], + }, + { + value: '2208', + label: '盐城', + children: [ + { value: '2212', label: '盐都区' }, + { value: '2210', label: '东台市' }, + { value: '2209', label: '盐城市' }, + { value: '2211', label: '大丰市' }, + { value: '2213', label: '建湖县' }, + { value: '2214', label: '响水县' }, + { value: '2215', label: '阜宁县' }, + { value: '2216', label: '射阳县' }, + { value: '2217', label: '滨海县' }, + { value: '4034', label: '亭湖区' }, + { value: '320983', label: '其它区' }, + ], + }, + { + value: '2218', + label: '扬州', + children: [ + { value: '2219', label: '扬州市' }, + { value: '2220', label: '高邮市' }, + { value: '2221', label: '江都市' }, + { value: '2222', label: '仪征市' }, + { value: '2223', label: '宝应县' }, + { value: '4035', label: '广陵区' }, + { value: '4036', label: '邗江区' }, + { value: '4037', label: '维扬区' }, + { value: '321093', label: '其它区' }, + ], + }, + { + value: '2224', + label: '泰州', + children: [ + { value: '2225', label: '泰州市' }, + { value: '2226', label: '泰兴市' }, + { value: '2227', label: '姜堰市' }, + { value: '2228', label: '靖江市' }, + { value: '2229', label: '兴化市' }, + { value: '4022', label: '高港区' }, + { value: '4023', label: '海陵区' }, + { value: '321285', label: '其它区' }, + ], + }, + { + value: '2230', + label: '南通', + children: [ + { value: '2231', label: '南通市' }, + { value: '2232', label: '如皋市' }, + { value: '2233', label: '通州市' }, + { value: '2234', label: '海门市' }, + { value: '2235', label: '启东市' }, + { value: '2236', label: '海安县' }, + { value: '2237', label: '如东县' }, + { value: '4013', label: '崇川区' }, + { value: '4014', label: '港闸区' }, + { value: '320694', label: '其它区' }, + ], + }, + { + value: '2238', + label: '镇江', + children: [ + { value: '2239', label: '镇江市' }, + { value: '2240', label: '丹阳市' }, + { value: '2241', label: '扬中市' }, + { value: '2242', label: '句容市' }, + { value: '4038', label: '丹徒区' }, + { value: '4039', label: '润州区' }, + { value: '4367', label: '京口区' }, + { value: '321184', label: '其它区' }, + ], + }, + { + value: '2243', + label: '常州', + children: [ + { value: '2246', label: '溧阳市' }, + { value: '2244', label: '常州市' }, + { value: '2245', label: '金坛市' }, + { value: '4432', label: '钟楼区' }, + { value: '4836', label: '戚墅堰区' }, + { value: '4837', label: '天宁区' }, + { value: '4838', label: '武进区' }, + { value: '4839', label: '新北区' }, + { value: '320483', label: '其它区' }, + ], + }, + { + value: '2247', + label: '无锡', + children: [ + { value: '2248', label: '无锡市' }, + { value: '2249', label: '江阴市' }, + { value: '2250', label: '宜兴市' }, + { value: '4024', label: '北塘区' }, + { value: '4025', label: '滨湖区' }, + { value: '4026', label: '崇安区' }, + { value: '4027', label: '惠山区' }, + { value: '4028', label: '南长区' }, + { value: '4029', label: '锡山区' }, + { value: '320296', label: '新区' }, + { value: '320297', label: '其它区' }, + ], + }, + { + value: '2251', + label: '苏州', + children: [ + { value: '2252', label: '苏州市' }, + { value: '2253', label: '常熟市' }, + { value: '2254', label: '张家港市' }, + { value: '2255', label: '太仓市' }, + { value: '2256', label: '昆山市' }, + { value: '2257', label: '吴江市' }, + { value: '4015', label: '沧浪区' }, + { value: '4016', label: '虎丘区' }, + { value: '4017', label: '金阊区' }, + { value: '4018', label: '平江区' }, + { value: '4019', label: '吴中区' }, + { value: '4020', label: '相城区' }, + { value: '320594', label: '新区' }, + { value: '320595', label: '园区' }, + { value: '320596', label: '其它区' }, + ], + }, + ], + value: '2177', + label: '江苏', + }, + { + children: [ + { + value: '2362', + label: '沈阳', + children: [ + { value: '2363', label: '沈阳市' }, + { value: '2364', label: '新民市' }, + { value: '2365', label: '法库县' }, + { value: '2366', label: '辽中县' }, + { value: '2367', label: '康平县' }, + { value: '4093', label: '大东区' }, + { value: '4094', label: '东陵区' }, + { value: '4095', label: '和平区' }, + { value: '4096', label: '皇姑区' }, + { value: '4097', label: '沈北新区' }, + { value: '4098', label: '沈河区' }, + { value: '4099', label: '苏家屯区' }, + { value: '4100', label: '于洪区' }, + { value: '4375', label: '铁西区' }, + { value: '210113', label: '新城子区' }, + { value: '210182', label: '浑南新区' }, + { value: '210183', label: '张士开发区' }, + { value: '210185', label: '其它区' }, + ], + }, + { + value: '2368', + label: '大连', + children: [ + { value: '2369', label: '大连市' }, + { value: '2370', label: '瓦房店市' }, + { value: '2371', label: '普兰店市' }, + { value: '2372', label: '庄河市' }, + { value: '2373', label: '长海县' }, + { value: '4066', label: '甘井子区' }, + { value: '4067', label: '金州区' }, + { value: '4068', label: '沙河口区' }, + { value: '4069', label: '西岗区' }, + { value: '4070', label: '中山区' }, + { value: '4371', label: '旅顺口区' }, + { value: '210297', label: '岭前区' }, + { value: '210298', label: '其它区' }, + ], + }, + { + value: '2374', + label: '鞍山', + children: [ + { value: '2375', label: '鞍山市' }, + { value: '2376', label: '海城市' }, + { value: '2377', label: '台安县' }, + { value: '2378', label: '岫岩满族自治县' }, + { value: '4057', label: '立山区' }, + { value: '4058', label: '千山区' }, + { value: '4059', label: '铁东区' }, + { value: '4060', label: '铁西区' }, + { value: '210382', label: '其它区' }, + ], + }, + { + value: '2379', + label: '抚顺', + children: [ + { value: '2380', label: '抚顺市' }, + { value: '2381', label: '抚顺县' }, + { value: '2382', label: '清原满族自治县' }, + { value: '2383', label: '新宾满族自治县' }, + { value: '4074', label: '东洲区' }, + { value: '4075', label: '顺城区' }, + { value: '4076', label: '新抚区' }, + { value: '4372', label: '望花区' }, + { value: '210424', label: '其它区' }, + ], + }, + { + value: '2384', + label: '本溪', + children: [ + { value: '2385', label: '本溪市' }, + { value: '2386', label: '本溪满族自治县' }, + { value: '2387', label: '桓仁满族自治县' }, + { value: '4061', label: '南芬区' }, + { value: '4062', label: '平山区' }, + { value: '4063', label: '溪湖区' }, + { value: '4370', label: '明山区' }, + { value: '210523', label: '其它区' }, + ], + }, + { + value: '2388', + label: '丹东', + children: [ + { value: '2389', label: '丹东市' }, + { value: '2390', label: '东港市' }, + { value: '2391', label: '凤城市' }, + { value: '2392', label: '宽甸满族自治县' }, + { value: '4071', label: '元宝区' }, + { value: '4072', label: '振安区' }, + { value: '4073', label: '振兴区' }, + { value: '210683', label: '其它区' }, + ], + }, + { + value: '2393', + label: '锦州', + children: [ + { value: '2396', label: '北镇市' }, + { value: '2394', label: '锦州市' }, + { value: '2395', label: '凌海市' }, + { value: '2397', label: '黑山县' }, + { value: '2398', label: '义县' }, + { value: '4084', label: '古塔区' }, + { value: '4085', label: '凌河区' }, + { value: '4086', label: '太和区' }, + { value: '210783', label: '其它区' }, + ], + }, + { + value: '2399', + label: '葫芦岛', + children: [ + { value: '2400', label: '葫芦岛市' }, + { value: '2401', label: '兴城市' }, + { value: '2402', label: '绥中县' }, + { value: '2403', label: '建昌县' }, + { value: '4082', label: '连山区' }, + { value: '4083', label: '南票区' }, + { value: '4373', label: '龙港区' }, + { value: '211482', label: '其它区' }, + ], + }, + { + value: '2404', + label: '营口', + children: [ + { value: '2405', label: '营口市' }, + { value: '2406', label: '大石桥市' }, + { value: '2407', label: '盖州市' }, + { value: '4103', label: '鲅鱼圈区' }, + { value: '4104', label: '老边区' }, + { value: '4105', label: '西市区' }, + { value: '4106', label: '站前区' }, + { value: '210883', label: '其它区' }, + ], + }, + { + value: '2408', + label: '盘锦', + children: [ + { value: '2409', label: '盘锦市' }, + { value: '2410', label: '盘山县' }, + { value: '2411', label: '大洼县' }, + { value: '4092', label: '兴隆台区' }, + { value: '4374', label: '双台子区' }, + { value: '211123', label: '其它区' }, + ], + }, + { + value: '2412', + label: '阜新', + children: [ + { value: '2413', label: '阜新市' }, + { value: '2414', label: '彰武县' }, + { value: '2415', label: '阜新蒙古族自治县' }, + { value: '4077', label: '海州区' }, + { value: '4078', label: '清河门区' }, + { value: '4079', label: '太平区' }, + { value: '4080', label: '细河区' }, + { value: '4081', label: '新邱区' }, + { value: '210923', label: '其它区' }, + ], + }, + { + value: '2416', + label: '辽阳', + children: [ + { value: '2417', label: '辽阳市' }, + { value: '2418', label: '灯塔市' }, + { value: '2419', label: '辽阳县' }, + { value: '4087', label: '白塔区' }, + { value: '4088', label: '弓长岭区' }, + { value: '4089', label: '宏伟区' }, + { value: '4090', label: '太子河区' }, + { value: '4091', label: '文圣区' }, + { value: '211082', label: '其它区' }, + ], + }, + { + value: '2420', + label: '铁岭', + children: [ + { value: '2421', label: '铁岭市' }, + { value: '2422', label: '调兵山市' }, + { value: '2423', label: '开原市' }, + { value: '2424', label: '铁岭县' }, + { value: '2425', label: '昌图县' }, + { value: '2426', label: '西丰县' }, + { value: '4101', label: '清河区' }, + { value: '4102', label: '银州区' }, + { value: '211283', label: '其它区' }, + ], + }, + { + value: '2427', + label: '朝阳', + children: [ + { value: '2428', label: '朝阳市' }, + { value: '2429', label: '凌源市' }, + { value: '2430', label: '北票市' }, + { value: '2431', label: '朝阳县' }, + { value: '2432', label: '建平县' }, + { value: '2433', label: '喀喇沁左翼蒙古族自治县' }, + { value: '4064', label: '龙城区' }, + { value: '4065', label: '双塔区' }, + { value: '211383', label: '其它区' }, + ], + }, + ], + value: '2361', + label: '辽宁', + }, + { + children: [ + { + value: '2435', + label: '呼和浩特', + children: [ + { value: '2436', label: '呼和浩特市' }, + { value: '2437', label: '托克托县' }, + { value: '2438', label: '清水河县' }, + { value: '2439', label: '武川县' }, + { value: '2440', label: '和林格尔县' }, + { value: '2441', label: '土默特左旗' }, + { value: '4116', label: '赛罕区' }, + { value: '4117', label: '新城区' }, + { value: '4118', label: '玉泉区' }, + { value: '4377', label: '回民区' }, + { value: '150126', label: '其它区' }, + ], + }, + { + value: '2442', + label: '包头', + children: [ + { value: '2443', label: '包头市' }, + { value: '2444', label: '固阳县' }, + { value: '2445', label: '土默特右旗' }, + { value: '2446', label: '达尔罕茂明安联合旗' }, + { value: '4107', label: '东河区' }, + { value: '4108', label: '九原区' }, + { value: '4109', label: '昆都仑区' }, + { value: '4110', label: '青山区' }, + { value: '4111', label: '石拐区' }, + { value: '4376', label: '白云矿区' }, + { value: '150224', label: '其它区' }, + ], + }, + { + value: '2447', + label: '乌海', + children: [ + { value: '2448', label: '乌海市' }, + { value: '4121', label: '海勃湾区' }, + { value: '4122', label: '海南区' }, + { value: '4123', label: '乌达区' }, + { value: '150305', label: '其它区' }, + ], + }, + { + value: '2449', + label: '赤峰', + children: [ + { value: '2459', label: '巴林右旗' }, + { value: '2450', label: '赤峰市' }, + { value: '2451', label: '宁城县' }, + { value: '2452', label: '林西县' }, + { value: '2453', label: '喀喇沁旗' }, + { value: '2454', label: '巴林左旗' }, + { value: '2455', label: '敖汉旗' }, + { value: '2456', label: '阿鲁科尔沁旗' }, + { value: '2457', label: '翁牛特旗' }, + { value: '2458', label: '克什克腾旗' }, + { value: '4112', label: '红山区' }, + { value: '4113', label: '松山区' }, + { value: '4114', label: '元宝山区' }, + { value: '150431', label: '其它区' }, + ], + }, + { + value: '2460', + label: '通辽', + children: [ + { value: '2461', label: '通辽市' }, + { value: '2462', label: '霍林郭勒市' }, + { value: '2463', label: '开鲁县' }, + { value: '2464', label: '科尔沁左翼中旗' }, + { value: '2465', label: '科尔沁左翼后旗' }, + { value: '2466', label: '库伦旗' }, + { value: '2467', label: '奈曼旗' }, + { value: '2468', label: '扎鲁特旗' }, + { value: '4120', label: '科尔沁区' }, + { value: '150582', label: '其它区' }, + ], + }, + { + value: '2469', + label: '鄂尔多斯', + children: [ + { value: '2472', label: '乌审旗' }, + { value: '2470', label: '鄂尔多斯市' }, + { value: '2471', label: '准格尔旗' }, + { value: '2473', label: '伊金霍洛旗' }, + { value: '2474', label: '鄂托克旗' }, + { value: '2475', label: '鄂托克前旗' }, + { value: '2476', label: '杭锦旗' }, + { value: '2477', label: '达拉特旗' }, + { value: '4115', label: '东胜区' }, + { value: '150628', label: '其它区' }, + ], + }, + { + value: '2478', + label: '呼伦贝尔', + children: [ + { value: '2491', label: '鄂温克族自治旗' }, + { value: '2479', label: '呼伦贝尔市' }, + { value: '2480', label: '满洲里市' }, + { value: '2481', label: '牙克石市' }, + { value: '2482', label: '扎兰屯市' }, + { value: '2483', label: '根河市' }, + { value: '2484', label: '额尔古纳市' }, + { value: '2485', label: '陈巴尔虎旗' }, + { value: '2486', label: '阿荣旗' }, + { value: '2487', label: '新巴尔虎左旗' }, + { value: '2488', label: '新巴尔虎右旗' }, + { value: '2489', label: '鄂伦春自治旗' }, + { value: '2490', label: '莫力达瓦达斡尔族自治旗' }, + { value: '4119', label: '海拉尔区' }, + { value: '150786', label: '其它区' }, + ], + }, + { + value: '2492', + label: '乌兰察布', + children: [ + { value: '2493', label: '乌兰察布市' }, + { value: '2494', label: '丰镇市' }, + { value: '2495', label: '兴和县' }, + { value: '2496', label: '卓资县' }, + { value: '2497', label: '商都县' }, + { value: '2498', label: '凉城县' }, + { value: '2499', label: '化德县' }, + { value: '2500', label: '察哈尔右翼前旗' }, + { value: '2501', label: '察哈尔右翼中旗' }, + { value: '2502', label: '察哈尔右翼后旗' }, + { value: '2503', label: '四子王旗' }, + { value: '4124', label: '集宁区' }, + { value: '150982', label: '其它区' }, + ], + }, + { + value: '2504', + label: '锡林郭勒盟', + children: [ + { value: '2505', label: '锡林浩特市' }, + { value: '2506', label: '二连浩特市' }, + { value: '2507', label: '多伦县' }, + { value: '2508', label: '阿巴嘎旗' }, + { value: '2509', label: '西乌珠穆沁旗' }, + { value: '2510', label: '东乌珠穆沁旗' }, + { value: '2511', label: '苏尼特左旗' }, + { value: '2512', label: '苏尼特右旗' }, + { value: '2513', label: '太仆寺旗' }, + { value: '2514', label: '正镶白旗' }, + { value: '2515', label: '正蓝旗' }, + { value: '2516', label: '镶黄旗' }, + ], + }, + { + value: '2517', + label: '巴彦淖尔市', + children: [ + { value: '2518', label: '临河区' }, + { value: '2524', label: '乌拉特后旗' }, + { value: '3785', label: '巴彦淖尔市' }, + { value: '2519', label: '五原县' }, + { value: '2520', label: '磴口县' }, + { value: '2521', label: '杭锦后旗' }, + { value: '2522', label: '乌拉特中旗' }, + { value: '2523', label: '乌拉特前旗' }, + { value: '150827', label: '其它区' }, + ], + }, + { + value: '2525', + label: '阿拉善盟', + children: [ + { value: '2526', label: '阿拉善左旗' }, + { value: '2527', label: '阿拉善右旗' }, + { value: '2528', label: '额济纳旗' }, + ], + }, + { + value: '2529', + label: '兴安盟', + children: [ + { value: '2530', label: '乌兰浩特市' }, + { value: '2531', label: '阿尔山市' }, + { value: '2532', label: '突泉县' }, + { value: '2533', label: '扎赉特旗' }, + { value: '2534', label: '科尔沁右翼前旗' }, + { value: '2535', label: '科尔沁右翼中旗' }, + ], + }, + ], + value: '2434', + label: '内蒙古', + }, + { + children: [ + { + value: '4847', + label: '香港岛', + children: [ + { value: '4848', label: '香港岛' }, + { value: '810101', label: '中西区' }, + { value: '810102', label: '湾仔' }, + { value: '810104', label: '南区' }, + ], + }, + { + value: '4849', + label: '九龙', + children: [ + { value: '4850', label: '九龙' }, + { value: '810201', label: '九龙城区' }, + { value: '810202', label: '油尖旺区' }, + { value: '810203', label: '深水埗区' }, + { value: '810204', label: '黄大仙区' }, + { value: '810205', label: '观塘区' }, + ], + }, + { + value: '4851', + label: '新界', + children: [ + { value: '4852', label: '新界' }, + { value: '810301', label: '北区' }, + { value: '810302', label: '大埔区' }, + { value: '810303', label: '沙田区' }, + { value: '810304', label: '西贡区' }, + { value: '810305', label: '元朗区' }, + { value: '810306', label: '屯门区' }, + { value: '810307', label: '荃湾区' }, + { value: '810308', label: '葵青区' }, + { value: '810309', label: '离岛区' }, + ], + }, + ], + value: '4846', + label: '香港', + }, + { + children: [ + { + value: '4854', + label: '澳门半岛', + children: [{ value: '4855', label: '澳门半岛' }], + }, + { + value: '4856', + label: '澳门离岛', + children: [{ value: '4857', label: '澳门离岛' }], + }, + ], + value: '4853', + label: '澳门', + }, + { + children: [ + { + value: '4859', + label: '台北县', + children: [{ value: '4860', label: '台北县' }], + }, + { + value: '4861', + label: '宜兰县', + children: [{ value: '4862', label: '宜兰县' }], + }, + { + value: '4863', + label: '桃园县', + children: [{ value: '4864', label: '桃园县' }], + }, + { + value: '4865', + label: '新竹县', + children: [{ value: '4866', label: '新竹县' }], + }, + { + value: '4867', + label: '苗栗县', + children: [{ value: '4868', label: '苗栗县' }], + }, + { + value: '4869', + label: '台中县', + children: [{ value: '4870', label: '台中县' }], + }, + { + value: '4871', + label: '彰化县', + children: [{ value: '4872', label: '彰化县' }], + }, + { + value: '4873', + label: '南投县', + children: [{ value: '4874', label: '南投县' }], + }, + { + value: '4875', + label: '云林县', + children: [{ value: '4876', label: '云林县' }], + }, + { + value: '4877', + label: '嘉义县', + children: [{ value: '4878', label: '嘉义县' }], + }, + { + value: '4879', + label: '台南县', + children: [{ value: '4880', label: '台南县' }], + }, + { + value: '4881', + label: '高雄县', + children: [{ value: '4882', label: '高雄县' }], + }, + { + value: '4883', + label: '屏东县', + children: [{ value: '4884', label: '屏东县' }], + }, + { + value: '4885', + label: '台东县', + children: [{ value: '4886', label: '台东县' }], + }, + { + value: '4887', + label: '花莲县', + children: [{ value: '4888', label: '花莲县' }], + }, + { + value: '4889', + label: '澎湖县', + children: [{ value: '4890', label: '澎湖县' }], + }, + { + value: '4891', + label: '基隆市', + children: [ + { value: '4892', label: '基隆市' }, + { value: '710701', label: '仁爱区' }, + { value: '710702', label: '信义区' }, + { value: '710703', label: '中正区' }, + { value: '710705', label: '安乐区' }, + { value: '710706', label: '暖暖区' }, + { value: '710707', label: '七堵区' }, + { value: '710708', label: '其它区' }, + ], + }, + { + value: '4893', + label: '新竹市', + children: [ + { value: '4894', label: '新竹市' }, + { value: '710802', label: '北区' }, + { value: '710803', label: '香山区' }, + { value: '710804', label: '其它区' }, + ], + }, + { + value: '4895', + label: '台中市', + children: [ + { value: '4896', label: '台中市' }, + { value: '710401', label: '中区' }, + { value: '710403', label: '南区' }, + { value: '710405', label: '北区' }, + { value: '710406', label: '北屯区' }, + { value: '710407', label: '西屯区' }, + { value: '710408', label: '南屯区' }, + { value: '710409', label: '其它区' }, + ], + }, + { + value: '4897', + label: '嘉义市', + children: [ + { value: '4898', label: '嘉义市' }, + { value: '710903', label: '其它区' }, + ], + }, + { + value: '4899', + label: '台南市', + children: [ + { value: '4900', label: '台南市' }, + { value: '710301', label: '中西区' }, + { value: '710303', label: '南区' }, + { value: '710304', label: '北区' }, + { value: '710305', label: '安平区' }, + { value: '710306', label: '安南区' }, + { value: '710307', label: '其它区' }, + ], + }, + { + value: '4901', + label: '台北市', + children: [ + { value: '4902', label: '台北市' }, + { value: '710101', label: '中正区' }, + { value: '710106', label: '万华区' }, + { value: '710107', label: '信义区' }, + { value: '710108', label: '士林区' }, + { value: '710109', label: '北投区' }, + { value: '710110', label: '内湖区' }, + { value: '710111', label: '南港区' }, + { value: '710112', label: '文山区' }, + { value: '710113', label: '其它区' }, + ], + }, + { + value: '4903', + label: '高雄市', + children: [ + { value: '4904', label: '高雄市' }, + { value: '710202', label: '前金区' }, + { value: '710203', label: '芩雅区' }, + { value: '710204', label: '盐埕区' }, + { value: '710205', label: '鼓山区' }, + { value: '710206', label: '旗津区' }, + { value: '710207', label: '前镇区' }, + { value: '710208', label: '三民区' }, + { value: '710209', label: '左营区' }, + { value: '710210', label: '楠梓区' }, + { value: '710211', label: '小港区' }, + { value: '710212', label: '其它区' }, + ], + }, + { + value: '4905', + label: '金门县', + children: [{ value: '4906', label: '金门' }], + }, + { + value: '4907', + label: '连江县', + children: [{ value: '4908', label: '连江' }], + }, + { + value: '4908', + label: '新北市', + children: [{ value: '711100', label: '新北市' }], + }, + ], + value: '4858', + label: '台湾', + }, + ]; + + if (hasDisabled) { + dataSource[1].disabled = true; + } + + return dataSource; +}; + +function render(lang: keyof typeof i18nMap = 'en-us') { + const i18n = i18nMap[lang]; + const dataSource = createDataSource(i18n.option); + const disabledDataSource = createDataSource(i18n.option, true); + + ReactDOM.render( +
    + + + + + + + + + + + + + + + + +
    , + document.getElementById('container') + ); +} + +window.renderDemo = function (lang) { + render(lang); +}; + +window.renderDemo(); + +initDemo('cascader'); diff --git a/components/cascader/__tests__/a11y-spec.js b/components/cascader/__tests__/a11y-spec.js deleted file mode 100644 index dd60edd7c7..0000000000 --- a/components/cascader/__tests__/a11y-spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Cascader from '../index'; -import '../style'; -import { unmount, testReact, mountReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -const ChinaArea = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [{ value: '2975', label: '西安市' }, { value: '2976', label: '高陵县' }], - }, - { - value: '2980', - label: '铜川', - children: [{ value: '2981', label: '铜川市' }, { value: '2982', label: '宜君县' }], - }, - ], - }, - { - value: '3078', - label: '四川', - }, -]; - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Cascader A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations when expanded', async () => { - wrapper = await testReact(); - return wrapper; - }); -}); diff --git a/components/cascader/__tests__/a11y-spec.tsx b/components/cascader/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..d2205f8f15 --- /dev/null +++ b/components/cascader/__tests__/a11y-spec.tsx @@ -0,0 +1,41 @@ +import React from 'react'; +import Cascader from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +const ChinaArea = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3078', + label: '四川', + }, +]; + +describe('Cascader A11y', () => { + it('should not have any violations when expanded', async () => { + await testReact( + + ); + }); +}); diff --git a/components/cascader/__tests__/index-spec.js b/components/cascader/__tests__/index-spec.js deleted file mode 100644 index 13d25d1fbc..0000000000 --- a/components/cascader/__tests__/index-spec.js +++ /dev/null @@ -1,744 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils from 'react-dom/test-utils'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import cloneDeep from 'lodash.clonedeep'; -import { KEYCODE } from '../../util'; -import Cascader from '../index'; -import '../style'; - -/* eslint-disable react/jsx-filename-extension, no-unused-expressions */ -/* global describe it afterEach */ - -Enzyme.configure({ adapter: new Adapter() }); - -function freeze(dataSource) { - return dataSource.map(item => { - const { children } = item; - children && freeze(children); - return Object.freeze({ ...item }); - }); -} - -const ChinaArea = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [{ value: '2975', label: '西安市' }, { value: '2976', label: '高陵县' }], - }, - { - value: '2980', - label: '铜川', - children: [{ value: '2981', label: '铜川市' }, { value: '2982', label: '宜君县' }], - }, - ], - }, - { - value: '3078', - label: '四川', - }, -]; - -describe('Cascader', () => { - let wrapper; - - afterEach(() => { - const overlay = document.querySelectorAll('.next-overlay-wrapper'); - overlay.forEach(dom => { - document.body.removeChild(dom); - }); - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should render single cascader', () => { - const defaultValue = '2975'; - const defaultExpandedValue = ['2973', '2974']; - let changeCalled = false; - let expandCalled = false; - const handleChange = (v, d, e) => { - changeCalled = true; - assert(v === '2980'); - delete d.children; - delete d._source; - assert.deepEqual(d, { - value: '2980', - label: '铜川', - pos: '0-0-1', - }); - e.selectedPath.forEach(d => { - delete d.children; - delete d._source; - }); - assert.deepEqual(e, { - selectedPath: [ - { - value: '2973', - label: '陕西', - pos: '0-0', - }, - { - value: '2980', - label: '铜川', - pos: '0-0-1', - }, - ], - }); - }; - const handleExpand = ev => { - expandCalled = true; - assert.deepEqual(ev, ['2973', '2980']); - }; - wrapper = mount( - - ); - compareDOMAndData(wrapper, defaultValue, defaultExpandedValue); - - findItem(wrapper, 1, 1).simulate('click'); - compareDOMAndData(wrapper, '2980', ['2973', '2980']); - assert(changeCalled); - assert(expandCalled); - }); - - it('should render single cascader under control', () => { - let value = '2975'; - let expandedValue = ['2973', '2974']; - let changeCalled = false; - let expandCalled = false; - const handleChange = (v, d, e) => { - changeCalled = true; - assert(v === '2980'); - delete d.children; - delete d._source; - assert.deepEqual(d, { - value: '2980', - label: '铜川', - pos: '0-0-1', - }); - e.selectedPath.forEach(d => { - delete d.children; - delete d._source; - }); - assert.deepEqual(e, { - selectedPath: [ - { - value: '2973', - label: '陕西', - pos: '0-0', - }, - { - value: '2980', - label: '铜川', - pos: '0-0-1', - }, - ], - }); - value = v; - wrapper.setProps({ value }); - }; - const handleExpand = ev => { - expandCalled = true; - assert.deepEqual(ev, ['2973', '2980']); - - expandedValue = ev; - wrapper.setProps({ value, expandedValue }); - }; - wrapper = mount( - - ); - compareDOMAndData(wrapper, value, expandedValue); - - wrapper.setProps({ dataSource: ChinaArea }); - - findItem(wrapper, 1, 1).simulate('click'); - compareDOMAndData(wrapper, value, expandedValue); - assert(changeCalled); - assert(expandCalled); - - wrapper.setProps({ - defaultValue: '2974', - defaultExpandedValue: ['2973', '2974'], - }); - compareDOMAndData(wrapper, value, expandedValue); - }); - - it('should not trigger onChange when click the selected item', () => { - const handleChange = () => { - assert(false); - }; - wrapper = mount( - - ); - findItem(wrapper, 1, 1).simulate('click'); - }); - - it('should support remove title', () => { - const data = cloneDeep(ChinaArea); - - data[0].title = ''; - - wrapper = mount(); - assert( - wrapper - .find('.next-menu-item') - .at(0) - .getDOMNode() - .getAttribute('title') === '' - ); - assert( - wrapper - .find('.next-menu-item') - .at(1) - .getDOMNode() - .getAttribute('title') === '四川' - ); - delete data[0].title; - }); - - it('could only select leaf item when set canOnlySelectLeaf to true', () => { - const handleChange = () => { - assert(false); - }; - wrapper = mount( - - ); - findItem(wrapper, 1, 1).simulate('click'); - }); - - it('could only check checkbox of leaf item when set canOnlyCheckLeaf to true', () => { - wrapper = mount( - - ); - assert(findItem(wrapper, 0, 0).find('label.next-checkbox').length === 0); - }); - - it('should expand menu by hover when set expandTriggerType to hover', () => { - let expandedValue; - let expandCalled = false; - const handleExpand = value => { - expandCalled = true; - assert.deepEqual(expandedValue, value); - }; - wrapper = mount( - - ); - expandedValue = ['2973', '2980']; - findItem(wrapper, 1, 1).simulate('mouseenter'); - assert(expandCalled); - expandCalled = false; - - expandedValue = ['2973', '2974']; - wrapper.simulate('mouseleave'); - assert(expandCalled); - expandCalled = false; - }); - - it('should render multiple cascader', done => { - const dataSource = [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - }, - { - value: '2976', - label: '高陵县', - }, - ], - }, - { - value: '2980', - label: '铜川', - }, - ], - }, - ]; - let changeCalled = false; - let value; - let data; - let extra; - const handleChange = (v, d, e) => { - d = filter$Source(d); - const item = { - ...e.currentData, - }; - delete item._source; - delete item.children; - e = { - ...e, - currentData: item, - checkedData: filter$Source(e.checkedData), - indeterminateData: filter$Source(e.indeterminateData), - }; - assert.deepEqual(value, sortByValue(v, true)); - assert.deepEqual(data, sortByValue(d)); - e.checkedData = sortByValue(e.checkedData); - e.indeterminateData = sortByValue(e.indeterminateData); - assert.deepEqual(extra, e); - changeCalled = true; - }; - wrapper = mount( - - ); - - const item00 = findItem(wrapper, 0, 0); - const item10 = findItem(wrapper, 1, 0); - const item20 = findItem(wrapper, 2, 0); - compareIndeterminate(item00); - compareIndeterminate(item10); - compareChecked(item20); - - (value = ['2973']), (data = [{ value: '2973', label: '陕西', pos: '0-0' }]); - extra = { - checked: true, - currentData: { value: '2973', label: '陕西', pos: '0-0' }, - checkedData: [ - { value: '2973', label: '陕西', pos: '0-0' }, - { value: '2974', label: '西安', pos: '0-0-0' }, - { value: '2975', label: '西安市', pos: '0-0-0-0' }, - { value: '2976', label: '高陵县', pos: '0-0-0-1' }, - { value: '2980', label: '铜川', pos: '0-0-1' }, - ], - indeterminateData: [], - }; - checkItem(item00, true); - compareChecked(findItem(wrapper, 0, 0)); - findItem(wrapper, 1).forEach(compareChecked); - findItem(wrapper, 2).forEach(compareChecked); - assert(changeCalled); - changeCalled = false; - - setTimeout(() => { - (value = ['2980']), (data = [{ value: '2980', label: '铜川', pos: '0-0-1' }]); - extra = { - checked: false, - currentData: { value: '2974', label: '西安', pos: '0-0-0' }, - checkedData: [{ value: '2980', label: '铜川', pos: '0-0-1' }], - indeterminateData: [{ value: '2973', label: '陕西', pos: '0-0' }], - }; - checkItem(findItem(wrapper, 1, 0), false); - compareIndeterminate(findItem(wrapper, 0, 0)); - compareNotChecked(findItem(wrapper, 1, 0)); - findItem(wrapper, 2).forEach(compareNotChecked); - assert(changeCalled); - done(); - }, 20); - }); - - it('should render multiple cascader when set checkStrictly to true', () => { - let changeCalled = false; - let value; - let data; - let extra; - const handleChange = (v, d, e) => { - d.forEach(d => delete d._source); - e.checkedData.forEach(d => delete d._source); - delete e.currentData._source; - assert.deepEqual(value, sortByValue(v, true)); - assert.deepEqual(data, sortByValue(d)); - e.checkedData = sortByValue(e.checkedData); - assert.deepEqual(extra, e); - changeCalled = true; - }; - wrapper = mount( - - ); - - const item00 = findItem(wrapper, 0, 0); - const item20 = findItem(wrapper, 2, 0); - compareChecked(item20); - - (value = ['2973', '2975']), - (data = [{ value: '2973', label: '陕西', pos: '0-0' }, { value: '2975', label: '西安市', pos: '0-0-0-0' }]); - extra = { - checked: true, - currentData: { value: '2973', label: '陕西', pos: '0-0' }, - checkedData: [ - { value: '2973', label: '陕西', pos: '0-0' }, - { value: '2975', label: '西安市', pos: '0-0-0-0' }, - ], - }; - checkItem(item00, true); - compareChecked(findItem(wrapper, 0, 0)); - assert(changeCalled); - changeCalled = false; - - (value = ['2973']), (data = [{ value: '2973', label: '陕西', pos: '0-0' }]); - extra = { - checked: false, - currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, - checkedData: [{ value: '2973', label: '陕西', pos: '0-0' }], - }; - checkItem(findItem(wrapper, 2, 0), false); - compareNotChecked(findItem(wrapper, 2, 0)); - assert(changeCalled); - }); - - it('should compute expanded value auto if set value but not set expanded value', () => { - wrapper = mount(); - const item00 = findItem(wrapper, 0, 0); - assert(item00.hasClass('next-cascader-menu-item')); - const item10 = findItem(wrapper, 1, 0); - assert(item10.hasClass('next-cascader-menu-item')); - }); - - it('should load data asynchronously when set loadData', done => { - const newWrapper = mount( - - ); - - function onLoadData() { - return new Promise(resolve => { - setTimeout(() => { - newWrapper.setProps( - { - dataSource: [ - { - value: '2973', - label: '陕西', - children: [ - { - value: '2974', - label: '西安', - children: [ - { - value: '2975', - label: '西安市', - isLeaf: true, - }, - { - value: '2976', - label: '高陵县', - isLeaf: true, - }, - ], - }, - { - value: '2980', - label: '铜川', - children: [ - { - value: '2981', - label: '铜川市', - isLeaf: true, - }, - { - value: '2982', - label: '宜君县', - isLeaf: true, - }, - ], - }, - ], - }, - ], - }, - resolve - ); - }, 500); - }); - } - - const item00 = findItem(newWrapper, 0, 0); - item00.simulate('click'); - assert(findItem(newWrapper, 0, 0).find('.next-cascader-menu-icon-loading').length > 0); - - setTimeout(() => { - assert( - findItem(newWrapper, 1, 0) - .text() - .trim() === '西安' - ); - assert( - findItem(newWrapper, 1, 1) - .text() - .trim() === '铜川' - ); - done(); - }, 1000); - }); - - it('should support listClassName and listStyle', () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - ReactDOM.render( - , - div - ); - - const list = div.querySelector('.next-cascader-menu-wrapper'); - assert(list.style.width === '400px'); - assert(list.style.height === '400px'); - assert(window.getComputedStyle(list.querySelector('.next-cascader-menu')).width === '400px'); - assert(window.getComputedStyle(list.querySelector('.next-cascader-menu')).height === '400px'); - assert(list.className.indexOf('custom') !== -1); - - ReactDOM.unmountComponentAtNode(div); - document.body.removeChild(div); - }); - - it('should support keyboard', () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - ReactDOM.render(, div); - - const item00 = findRealItem(0, 0); - item00.click(); - try { - assert(document.activeElement === item00); - const assertAE = assertActiveElement(); - assertAE(KEYCODE.DOWN, findRealItem(0, 1)); - assertAE(KEYCODE.UP, item00); - assertAE(KEYCODE.RIGHT, () => findRealItem(1, 0)); - assertAE(KEYCODE.LEFT, item00); - assert(document.querySelectorAll('.next-cascader-menu').length === 1); - - ReactDOM.unmountComponentAtNode(div); - document.body.removeChild(div); - } catch (err) { - ReactDOM.unmountComponentAtNode(div); - document.body.removeChild(div); - throw new Error(err); - } - }); - - it('should set the style of the cascader inner node', () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - ReactDOM.render( - , - div - ); - - const inner = document.querySelector('#cascader-style .next-cascader-inner'); - assert(inner.style.width === '600px'); - const lists = document.querySelectorAll('.next-cascader-menu-wrapper'); - assert(lists[lists.length - 1].className.indexOf('next-has-right-border') > -1); - - ReactDOM.unmountComponentAtNode(div); - document.body.removeChild(div); - }); - - it('support immutable data source', () => { - wrapper = mount(); - }); - - it('should support rtl', () => { - const div = document.createElement('div'); - document.body.appendChild(div); - - ReactDOM.render( - , - div - ); - - assert(document.getElementById('cascader-style').dir === 'rtl'); - ReactDOM.unmountComponentAtNode(div); - document.body.removeChild(div); - }); - - // Fix https://github.com/alibaba-fusion/next/issues/4472 - it('Empty items at first level can collapse the next level panel while cross value', () => { - const dataSource = [ - { - label: '1', - value: '1', - }, - { - label: '2', - value: '2', - children: [ - { - label: '2_1', - value: '2_1', - }, - ], - }, - ]; - const wrapper = mount(); - assert(wrapper.find('.next-cascader-menu-wrapper').length === 2); - const el = wrapper.find('.next-menu-item[title="1"]').getDOMNode(); - assert(el); - ReactTestUtils.Simulate.click(el); - wrapper.update(); - assert(wrapper.find('.next-cascader-menu-wrapper').length === 1); - }); -}); - -function compareDOMAndData(wrapper, value, expandedValue) { - let itemsData = ChinaArea; - const menus = wrapper.find('ul.next-cascader-menu'); - menus.forEach((menu, i) => { - let expandedIndex; - menu.find('li.next-cascader-menu-item').forEach((item, j) => { - assert(item.text().trim() === itemsData[j].label); - if (itemsData[j].value === value) { - assert(item.find('li.next-selected').length === 1); - } - if (itemsData[j].value === expandedValue[i]) { - assert(item.hasClass('next-expanded')); - expandedIndex = j; - } - }); - - if (i < menus.length - 1) { - itemsData = itemsData[expandedIndex].children; - } - }); -} - -function findItem(wrapper, menuIndex, itemIndex) { - return wrapper - .find('ul.next-cascader-menu') - .at(menuIndex) - .find('li.next-cascader-menu-item') - .at(itemIndex); -} - -function checkItem(item, check) { - const checkbox = item.find('.next-checkbox-wrapper input'); - checkbox.simulate('change', { target: { checked: check } }); -} - -function compareChecked(item) { - const checkbox = item.find('.next-checkbox-wrapper'); - assert(checkbox.hasClass('checked')); - assert(!checkbox.hasClass('indeterminate')); -} - -function compareIndeterminate(item) { - const checkbox = item.find('.next-checkbox-wrapper'); - assert(checkbox.hasClass('indeterminate')); - assert(!checkbox.hasClass('checked')); -} - -function compareNotChecked(item) { - const checkbox = item.find('.next-checkbox-wrapper'); - assert(!checkbox.hasClass('indeterminate')); - assert(!checkbox.hasClass('checked')); -} - -function sortByValue(data, isValue = false) { - if (!isValue) { - data.forEach(d => delete d.children); - } - - return data.sort((prev, next) => { - if (isValue) { - return prev - next; - } - - return prev.value - next.value; - }); -} - -function assertActiveElement() { - let activeElement = document.activeElement; - - return (keyCode, next) => { - ReactTestUtils.Simulate.keyDown(activeElement, { keyCode }); - next = typeof next === 'function' ? next() : next; - assert(document.activeElement === next); - activeElement = next; - }; -} - -function findRealItem(listIndex, itemIndex) { - return document.querySelectorAll('.next-cascader-menu')[listIndex].querySelectorAll('.next-cascader-menu-item')[ - itemIndex - ]; -} - -function filter$Source(data) { - if (!data) return; - - return [...data].map(it => { - const item = { - ...it, - }; - delete item._source; - return item; - }); -} diff --git a/components/cascader/__tests__/index-spec.tsx b/components/cascader/__tests__/index-spec.tsx new file mode 100644 index 0000000000..32de243987 --- /dev/null +++ b/components/cascader/__tests__/index-spec.tsx @@ -0,0 +1,803 @@ +import React, { + useState, + forwardRef, + useImperativeHandle, + type Dispatch, + type SetStateAction, +} from 'react'; +import cloneDeep from 'lodash.clonedeep'; +import type { SinonSpy } from 'cypress/types/sinon'; +import Cascader, { type CascaderDataItem, type CascaderProps } from '../index'; +import ConfigProvider from '../../config-provider'; +import '../style'; + +function freeze(dataSource: NonNullable) { + return dataSource.map(item => { + const { children } = item; + children && freeze(children); + return Object.freeze({ ...item }); + }); +} + +const ChinaArea: NonNullable = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3078', + label: '四川', + }, +]; + +function compareDOMAndData(value: string, expandedValue: string[]) { + const getTarget = (col: number, row: number, data = ChinaArea) => { + const expanded = [...expandedValue]; + while (col !== 0) { + const index = expanded.shift(); + const newData = data.find(item => item.value === index); + data = newData?.children as CascaderDataItem[]; + col--; + } + return data[row]; + }; + cy.get('ul.next-cascader-menu').each(($menu, i) => { + cy.wrap($menu) + .find('li.next-cascader-menu-item') + .each(($item, j) => { + const target = getTarget(i, j); + const targetLabel = target.label; + cy.wrap($item).should('have.text', targetLabel); + const curValue = target.value; + if (curValue === value) { + cy.wrap($item).should('have.class', 'next-selected'); + } + if (curValue === expandedValue[i]) { + cy.wrap($item).should('have.class', 'next-expanded'); + } + }); + }); +} + +function compareIndeterminate(item: ReturnType) { + item.find('.next-checkbox-wrapper') + .should('have.class', 'indeterminate') + .should('not.have.class', 'checked'); +} + +function compareChecked(item: ReturnType) { + item.find('.next-checkbox-wrapper') + .should('have.class', 'checked') + .should('not.have.class', 'indeterminate'); +} + +function compareNotChecked(item: ReturnType) { + item.find('.next-checkbox-wrapper') + .should('not.have.class', 'checked') + .should('not.have.class', 'indeterminate'); +} + +function findItem(menuIndex: number, itemIndex?: number) { + if (itemIndex !== undefined) { + return cy + .get('ul.next-cascader-menu') + .eq(menuIndex) + .find('li.next-cascader-menu-item') + .eq(itemIndex); + } + return cy.get('ul.next-cascader-menu').eq(menuIndex).find('li.next-cascader-menu-item'); +} + +function checkItem(item: ReturnType) { + return item.find('input').click({ force: true }); +} + +function filter$Source(data: Record[]) { + if (!data) return; + + return [...data].map(it => { + const item = { + ...it, + }; + delete item._source; + return item; + }); +} +function sortByValue & { value?: string })[]>( + data: T, + isValue = false +): T { + if (!isValue) { + data.forEach(d => { + if (typeof d === 'object') { + delete d.children; + } + }); + } + + return data.sort((prev, next) => { + if (isValue) { + return Number(prev) - Number(next); + } + if (typeof prev === 'object' && typeof next === 'object') { + return Number(prev.value) - Number(next.value); + } + return -1; + }) as T; +} + +function assertActiveElement() { + let activeElement = document.activeElement; + + return ( + events: string, + next: ReturnType | (() => ReturnType) + ) => { + cy.wrap(activeElement).type(events); + next = typeof next === 'function' ? next() : next; + next.then(($el: JQuery) => { + const element = $el.get(0); + cy.wrap(element).should('equal', document.activeElement); + activeElement = element; + }); + }; +} + +describe('Cascader', () => { + it('should render single cascader', () => { + const defaultValue = '2975'; + const defaultExpandedValue = ['2973', '2974']; + const handleChange = cy.spy().as('handleChange'); + const handleExpand = cy.spy().as('handleExpand'); + cy.mount( + + ); + compareDOMAndData(defaultValue, defaultExpandedValue); + + findItem(1, 1) + .click({ force: true }) + .then(() => { + compareDOMAndData('2980', ['2973', '2980']); + cy.get('@handleChange').should('have.been.calledOnce'); + cy.get('@handleChange').then($hc => { + const [v, d, e] = $hc.args[0] as Parameters< + NonNullable + >; + cy.wrap(v).should('equal', '2980'); + delete (d as CascaderDataItem).children; + delete (d as CascaderDataItem)._source; + cy.wrap(d).should('deep.equal', { + value: '2980', + label: '铜川', + pos: '0-0-1', + }); + e.selectedPath!.forEach(d => { + delete d.children; + delete d._source; + }); + cy.wrap(e).should('deep.equal', { + selectedPath: [ + { + value: '2973', + label: '陕西', + pos: '0-0', + }, + { + value: '2980', + label: '铜川', + pos: '0-0-1', + }, + ], + }); + }); + cy.get('@handleExpand').should('have.been.calledOnce'); + cy.get('@handleExpand').should('have.been.calledWith', ['2973', '2980']); + }); + }); + + it('should render single cascader under control', () => { + let VALUE = '2975'; + let EXPANDED = ['2973', '2974']; + const handleChange = cy.spy().as('handleChange'); + const handleExpand = cy.spy().as('handleExpand'); + interface DemoRef { + setDefaultValue: Dispatch>; + setDefaultExpandedValue: Dispatch>; + } + const Demo = forwardRef((props, ref) => { + const [value, setValue] = useState(VALUE); + const [expandedValue, setExpandedValue] = useState([...EXPANDED]); + const [defaultValue, setDefaultValue] = useState('2973'); + const [defaultExpandedValue, setDefaultExpandedValue] = useState(['2973']); + useImperativeHandle(ref, () => { + return { + setValue, + setExpandedValue, + setDefaultValue, + setDefaultExpandedValue, + }; + }); + + return ( + { + setValue(rest[0] as string); + VALUE = rest[0] as string; + handleChange(...rest); + }} + onExpand={ex => { + setExpandedValue(ex); + EXPANDED = ex; + handleExpand(ex); + }} + /> + ); + }); + + let demoRef: DemoRef | null; + + cy.mount( + { + demoRef = c; + }} + /> + ); + compareDOMAndData(VALUE, EXPANDED); + + findItem(1, 1) + .click() + .then(() => { + compareDOMAndData(VALUE, EXPANDED); + cy.get('@handleChange').should('be.calledOnce'); + cy.get('@handleExpand').should('be.calledOnce'); + demoRef!.setDefaultValue('2974'); + demoRef!.setDefaultExpandedValue(['2973', '2974']); + compareDOMAndData(VALUE, EXPANDED); + }); + }); + + it('should not trigger onChange when click the selected item', () => { + const handleChange = cy.spy().as('handleChange'); + cy.mount( + + ); + findItem(1, 1).click(); + cy.get('@handleChange').should('not.be.called'); + }); + + it('should support remove title', () => { + const data = cloneDeep(ChinaArea); + + data[0].title = ''; + + cy.mount(); + cy.get('.next-menu-item').eq(0).should('have.prop', 'title', ''); + cy.get('.next-menu-item').eq(1).should('have.prop', 'title', '四川'); + }); + + it('could only select leaf item when set canOnlySelectLeaf to true', () => { + const handleChange = cy.spy().as('handleChange'); + cy.mount( + + ); + findItem(1, 1).click(); + cy.get('@handleChange').should('not.be.called'); + }); + + it('could only check checkbox of leaf item when set canOnlyCheckLeaf to true', () => { + cy.mount( + + ); + findItem(0, 0).find('.next-checkbox').should('not.exist'); + findItem(0, 1).find('.next-checkbox').should('exist'); + }); + + it('should expand menu by hover when set expandTriggerType to hover', () => { + let expandedValue; + const handleExpand = cy.spy().as('handleExpand'); + cy.mount( + + ); + expandedValue = ['2973', '2980']; + findItem(1, 1).trigger('mouseover', { force: true }); + cy.get('@handleExpand').should('be.calledWith', expandedValue); + + expandedValue = ['2973', '2974']; + findItem(1, 1).trigger('mouseout', { force: true }); + cy.get('@handleExpand').should('be.calledWith', expandedValue); + }); + + it('should render multiple cascader', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + }, + { + value: '2976', + label: '高陵县', + }, + ], + }, + { + value: '2980', + label: '铜川', + }, + ], + }, + ]; + const handleChange = cy.spy().as('handleChange'); + cy.mount( + + ); + + const item00 = findItem(0, 0); + const item10 = findItem(1, 0); + const item20 = findItem(2, 0); + compareIndeterminate(item00); + compareIndeterminate(item10); + compareChecked(item20); + + checkItem(item00).then(() => { + cy.get('@handleChange').should('be.called'); + cy.get('@handleChange').then($hc => { + const [v, d, e] = $hc.args[0] as Parameters>; + const newD = filter$Source(d as CascaderDataItem[]); + const item = { + ...e.currentData, + }; + delete item._source; + delete item.children; + const newE = { + ...e, + currentData: item, + checkedData: filter$Source(e.checkedData!), + indeterminateData: filter$Source(e.indeterminateData!), + }; + cy.wrap(sortByValue(v as string[], true)).should('deep.equal', ['2973']); + cy.wrap(sortByValue(newD!)).should('deep.equal', [ + { value: '2973', label: '陕西', pos: '0-0' }, + ]); + newE.checkedData = sortByValue(newE.checkedData!); + newE.indeterminateData = sortByValue(newE.indeterminateData!); + cy.wrap(newE).should('deep.equal', { + checked: true, + currentData: { value: '2973', label: '陕西', pos: '0-0' }, + checkedData: [ + { value: '2973', label: '陕西', pos: '0-0' }, + { value: '2974', label: '西安', pos: '0-0-0' }, + { value: '2975', label: '西安市', pos: '0-0-0-0' }, + { value: '2976', label: '高陵县', pos: '0-0-0-1' }, + { value: '2980', label: '铜川', pos: '0-0-1' }, + ], + indeterminateData: [], + }); + }); + compareChecked(findItem(0, 0)); + [1, 2].forEach(index => { + findItem(index).each(item => { + compareChecked(cy.wrap(item)); + }); + }); + checkItem(findItem(1, 0)); + cy.get('@handleChange').should('be.called'); + compareIndeterminate(findItem(0, 0)); + compareNotChecked(findItem(1, 0)); + findItem(2).each(item => { + compareNotChecked(cy.wrap(item)); + }); + }); + }); + + it('should render multiple cascader when set checkStrictly to true', () => { + let curValue: any; + let curData: any; + let curExtra: any; + const handleChange = cy.spy().as('handleChange'); + cy.mount( + + ); + + const item00 = findItem(0, 0); + const item20 = findItem(2, 0); + compareChecked(item20); + const checkChange = ($hc: SinonSpy, value: any, data: any, extra: any, argsIndex = 0) => { + const [v, d, e] = $hc.args[argsIndex] as Parameters< + NonNullable + >; + (d as CascaderDataItem[]).forEach(item => delete item._source); + e.checkedData!.forEach(item => delete item._source); + delete e.currentData!._source; + cy.wrap(sortByValue(v as string[], true)).should('deep.equal', value); + cy.wrap(sortByValue(d as CascaderDataItem[])).should('deep.equal', data); + e.checkedData = sortByValue(e.checkedData!); + cy.wrap(e).should('deep.equal', extra); + }; + checkItem(item00).then(() => { + curValue = ['2973', '2975']; + curData = [ + { value: '2973', label: '陕西', pos: '0-0' }, + { value: '2975', label: '西安市', pos: '0-0-0-0' }, + ]; + curExtra = { + checked: true, + currentData: { value: '2973', label: '陕西', pos: '0-0' }, + checkedData: curData, + }; + cy.get('@handleChange').should('be.called'); + cy.get('@handleChange').then($hc => { + checkChange($hc, curValue, curData, curExtra); + }); + compareChecked(findItem(0, 0)); + checkItem(findItem(2, 0)).then(() => { + cy.get('@handleChange').should('be.called'); + curData = [{ value: '2973', label: '陕西', pos: '0-0' }]; + curExtra = { + checked: false, + currentData: { value: '2975', label: '西安市', pos: '0-0-0-0' }, + checkedData: [{ value: '2973', label: '陕西', pos: '0-0' }], + }; + curValue = ['2973']; + cy.get('@handleChange').then($hc => { + checkChange($hc, curValue, curData, curExtra, 1); + }); + compareNotChecked(findItem(2, 0)); + }); + }); + }); + + it('should compute expanded value auto if set value but not set expanded value', () => { + cy.mount(); + findItem(0, 0).should('exist'); + findItem(1, 0).should('exist'); + }); + + it('should load data asynchronously when set loadData', () => { + const Demo = () => { + const [dataSource, setDs] = useState([ + { + value: '2973', + label: '陕西', + isLeaf: false, + }, + ]); + function onLoadData() { + return new Promise(resolve => { + setTimeout(() => { + setDs([ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { + value: '2975', + label: '西安市', + isLeaf: true, + }, + { + value: '2976', + label: '高陵县', + isLeaf: true, + }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { + value: '2981', + label: '铜川市', + isLeaf: true, + }, + { + value: '2982', + label: '宜君县', + isLeaf: true, + }, + ], + }, + ], + }, + ]); + resolve(''); + }, 500); + }); + } + return ; + }; + + cy.mount(); + findItem(0, 0) + .click() + .then(() => { + findItem(0, 0).find('.next-cascader-menu-icon-loading').should('exist'); + findItem(1, 0).should('have.text', '西安'); + findItem(1, 1).should('have.text', '铜川'); + }); + }); + + it('should support listClassName and listStyle', () => { + cy.mount( + + ); + + cy.get('.next-cascader-menu-wrapper').should('have.css', 'width', '400px'); + cy.get('.next-cascader-menu-wrapper').should('have.css', 'height', '400px'); + cy.get('.next-cascader-menu').should('have.css', 'width', '400px'); + cy.get('.next-cascader-menu').should('have.css', 'height', '400px'); + cy.get('.next-cascader-menu-wrapper').should('have.class', 'custom'); + }); + + it('should support keyboard', () => { + cy.mount(); + + findItem(0, 0) + .click() + .then(() => { + findItem(0, 0).then($el => { + cy.wrap($el.get(0)).should('equal', document.activeElement); + }); + const assertAE = assertActiveElement(); + assertAE('{rightArrow}', () => findItem(1, 0)); + assertAE('{leftArrow}', findItem(0, 0)); + assertAE('{enter}', () => findItem(1, 0)); + assertAE('{esc}', findItem(0, 0)); + cy.get('.next-cascader-menu').should('have.length', 1); + }); + }); + + it('should set the style of the cascader inner node', () => { + cy.mount( + + ); + + cy.get('#cascader-style .next-cascader-inner').should('have.css', 'width', '600px'); + cy.get('.next-cascader-menu-wrapper').last().should('have.class', 'next-has-right-border'); + }); + + it('support immutable data source', () => { + cy.mount(); + }); + + it('should support rtl', () => { + cy.mount( + + ); + cy.get('#cascader-style').should('have.prop', 'dir', 'rtl'); + }); + + // Fix https://github.com/alibaba-fusion/next/issues/4472 + it('Empty items at first level can collapse the next level panel while cross value', () => { + const dataSource = [ + { + label: '1', + value: '1', + }, + { + label: '2', + value: '2', + children: [ + { + label: '2_1', + value: '2_1', + }, + ], + }, + ]; + cy.mount(); + cy.get('.next-cascader-menu-wrapper').should('have.length', 2); + cy.get('.next-menu-item[title="1"]').click(); + cy.get('.next-cascader-menu-wrapper').should('have.length', 1); + }); + + // Fix https://github.com/alibaba-fusion/next/issues/3704 + it('When using virtual scrolling, the background color should be white', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3371', + label: '新疆', + children: [ + { + value: '3430', + label: '巴音郭楞蒙古自治州', + children: [ + { value: '3431', label: '库尔勒市' }, + { value: '3432', label: '和静县' }, + ], + }, + ], + }, + ]; + cy.mount( +
    + +
    + ); + cy.get('.next-cascader-menu-wrapper').should( + 'have.css', + 'background-color', + 'rgb(255, 255, 255)' + ); + }); + + // Fix https://github.com/alibaba-fusion/next/issues/3852 + it('The Cascader component enforces isSelectIconRight to be false to prevent potential style conflicts that may arise when isSelectIconRight is set to true', () => { + const dataSource = [ + { + value: '2973', + label: '陕西', + children: [ + { + value: '2974', + label: '西安', + children: [ + { value: '2975', label: '西安市' }, + { value: '2976', label: '高陵县' }, + ], + }, + { + value: '2980', + label: '铜川', + children: [ + { value: '2981', label: '铜川市' }, + { value: '2982', label: '宜君县' }, + ], + }, + ], + }, + { + value: '3371', + label: '新疆', + children: [ + { + value: '3430', + label: '巴音郭楞蒙古自治州', + children: [ + { value: '3431', label: '库尔勒市' }, + { value: '3432', label: '和静县' }, + ], + }, + ], + }, + ]; + cy.mount( + + + + ); + findItem(0, 0) + .click({ force: true }) + .then(() => { + findItem(0, 0) + .find('.next-menu-icon-selected') + .should('not.have.class', 'next-menu-icon-right'); + }); + }); +}); diff --git a/components/cascader/cascader.jsx b/components/cascader/cascader.jsx deleted file mode 100644 index 994c67964a..0000000000 --- a/components/cascader/cascader.jsx +++ /dev/null @@ -1,866 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import cloneDeep from 'lodash.clonedeep'; -import cx from 'classnames'; -import Menu from '../menu'; -import { func, obj, dom } from '../util'; -import CascaderMenu from './menu'; -import CascaderMenuItem from './item'; -import { - filterChildValue, - getAllCheckedValues, - forEachEnableNode, - isSiblingOrSelf, - isDescendantOrSelf, - isNodeChecked, -} from './utils'; - -const { bindCtx } = func; -const { pickOthers } = obj; -const { addClass, removeClass, setStyle, getStyle } = dom; - -// 数据打平 -const flatDataSource = (data, prefix = '0', v2n = {}, p2n = {}) => { - data.forEach((item, index) => { - const { value, children } = item; - const pos = `${prefix}-${index}`; - const newValue = String(value); - - item.value = newValue; - - v2n[newValue] = p2n[pos] = { - ...item, - pos, - _source: item, - }; - - if (children && children.length) { - flatDataSource(children, pos, v2n, p2n); - } - }); - - return { v2n, p2n }; -}; - -function preHandleData(data, immutable) { - const _data = immutable ? cloneDeep(data) : data; - - try { - return flatDataSource(_data); - } catch (err) { - if ((err.message || '').match('Cannot assign to read only property')) { - // eslint-disable-next-line no-console - console.error(err.message, 'try to set immutable to true to allow immutable dataSource'); - } - throw err; - } -} - -const getExpandedValue = (v, _v2n, _p2n) => { - if (!v || !_v2n[v]) { - return []; - } - - const pos = _v2n[v].pos; - if (pos.split('-').length === 2) { - return []; - } - - const expandedMap = {}; - Object.keys(_p2n).forEach(p => { - if (isDescendantOrSelf(p, pos) && p !== pos) { - expandedMap[_p2n[p].value] = p; - } - }); - - return Object.keys(expandedMap).sort((prev, next) => { - return expandedMap[prev].split('-').length - expandedMap[next].split('-').length; - }); -}; - -const normalizeValue = value => { - if (value) { - if (Array.isArray(value)) { - return value; - } - - return [value]; - } - - return []; -}; - -/** - * Cascader - */ -class Cascader extends Component { - static propTypes = { - prefix: PropTypes.string, - rtl: PropTypes.bool, - pure: PropTypes.bool, - className: PropTypes.string, - /** - * 数据源,结构可参考下方说明 - */ - dataSource: PropTypes.arrayOf(PropTypes.object), - /** - * (非受控)默认值 - */ - defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - /** - * (受控)当前值 - */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), - /** - * 选中值改变时触发的回调函数 - * @param {String|Array} value 选中的值,单选时返回单个值,多选时返回数组 - * @param {Object|Array} data 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点 - * @param {Object} extra 额外参数 - * @param {Array} extra.selectedPath 单选时选中的数据的路径 - * @param {Boolean} extra.checked 多选时当前的操作是选中还是取消选中 - * @param {Object} extra.currentData 多选时当前操作的数据 - * @param {Array} extra.checkedData 多选时所有被选中的数据 - * @param {Array} extra.indeterminateData 多选时半选的数据 - */ - onChange: PropTypes.func, - onSelect: PropTypes.func, - /** - * (非受控)默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 - */ - defaultExpandedValue: PropTypes.arrayOf(PropTypes.string), - /** - * (受控)当前展开值 - */ - expandedValue: PropTypes.arrayOf(PropTypes.string), - /** - * 展开触发的方式 - */ - expandTriggerType: PropTypes.oneOf(['click', 'hover']), - /** - * 展开时触发的回调函数 - * @param {Array} expandedValue 各列展开值的数组 - */ - onExpand: PropTypes.func, - /** - * 是否开启虚拟滚动 - */ - useVirtual: PropTypes.bool, - /** - * 是否多选 - */ - multiple: PropTypes.bool, - /** - * 单选时是否只能选中叶子节点 - */ - canOnlySelectLeaf: PropTypes.bool, - /** - * 多选时是否只能选中叶子节点 - */ - canOnlyCheckLeaf: PropTypes.bool, - /** - * 父子节点是否选中不关联 - */ - checkStrictly: PropTypes.bool, - /** - * 每列列表样式对象 - */ - listStyle: PropTypes.object, - /** - * 每列列表类名 - */ - listClassName: PropTypes.string, - /** - * 每列列表项渲染函数 - * @param {Object} data 数据 - * @return {ReactNode} 列表项内容 - */ - itemRender: PropTypes.func, - /** - * 异步加载数据函数 - * @param {Object} data 当前点击异步加载的数据 - * @param {Object} source 当前点击数据,source是原始对象 - */ - loadData: PropTypes.func, - searchValue: PropTypes.string, - onBlur: PropTypes.func, - filteredPaths: PropTypes.array, - filteredListStyle: PropTypes.object, - resultRender: PropTypes.func, - /** - * 是否是不可变数据 - * @version 1.23 - */ - immutable: PropTypes.bool, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - pure: false, - dataSource: [], - defaultValue: null, - canOnlySelectLeaf: false, - canOnlyCheckLeaf: false, - expandTriggerType: 'click', - multiple: false, - useVirtual: false, - checkStrictly: false, - itemRender: item => item.label, - immutable: false, - }; - - constructor(props, context) { - super(props, context); - - const { - defaultValue, - value, - defaultExpandedValue, - expandedValue, - dataSource, - multiple, - checkStrictly, - canOnlyCheckLeaf, - loadData, - immutable, - } = props; - - const { v2n, p2n } = preHandleData(dataSource, immutable); - - let normalizedValue = normalizeValue(typeof value === 'undefined' ? defaultValue : value); - - if (!loadData) { - normalizedValue = normalizedValue.filter(v => v2n[v]); - } - - const realExpandedValue = - typeof expandedValue === 'undefined' - ? typeof defaultExpandedValue === 'undefined' - ? getExpandedValue(normalizedValue[0], v2n, p2n) - : normalizeValue(defaultExpandedValue) - : normalizeValue(expandedValue); - const st = { - value: normalizedValue, - expandedValue: realExpandedValue, - isExpandedValueSetByAction: false, - }; - if (multiple && !checkStrictly && !canOnlyCheckLeaf) { - st.value = getAllCheckedValues(st.value, v2n, p2n); - } - - this.lastExpandedValue = [...st.expandedValue]; - this.state = { - ...st, - _v2n: v2n, - _p2n: p2n, - }; - - bindCtx(this, [ - 'handleMouseLeave', - 'handleFocus', - 'handleFold', - 'getCascaderNode', - 'getCascaderInnerNode', - 'onBlur', - ]); - } - - static getDerivedStateFromProps(props, state) { - const { v2n, p2n } = preHandleData(props.dataSource, props.immutable); - const states = {}; - - if ('value' in props) { - states.value = normalizeValue(props.value); - if (!props.loadData) { - states.value = states.value.filter(v => v2n[v]); - } - - const { multiple, checkStrictly, canOnlyCheckLeaf } = props; - if (multiple && !checkStrictly && !canOnlyCheckLeaf) { - states.value = getAllCheckedValues(states.value, v2n, p2n); - } - - if ( - // 非受控模式下,若已经通过用户事件调整了expandedValue,则忽略下面的空值兜底处理 - !state.isExpandedValueSetByAction && - !state.expandedValue.length && - !('expandedValue' in props) - ) { - states.expandedValue = getExpandedValue(states.value[0], v2n, p2n); - } - } - - if ('expandedValue' in props) { - states.expandedValue = normalizeValue(props.expandedValue); - // 受控模式则重置isExpandedValueSetByAction - states.isExpandedValueSetByAction = false; - } - - return { - ...states, - _v2n: v2n, - _p2n: p2n, - }; - } - componentDidMount() { - this.setCascaderInnerWidth(); - } - - componentDidUpdate() { - this.setCascaderInnerWidth(); - } - - getCascaderNode(ref) { - this.cascader = ref; - } - - getCascaderInnerNode(ref) { - this.cascaderInner = ref; - } - - setCascaderInnerWidth() { - if (!this.cascaderInner) { - return; - } - const menus = [].slice.call(this.cascaderInner.querySelectorAll(`.${this.props.prefix}cascader-menu-wrapper`)); - if (menus.length === 0) { - return; - } - - const menusWidth = Math.ceil( - menus.reduce((ret, menu) => { - return ret + Math.ceil(menu.getBoundingClientRect().width); - }, 0) - ); - - if (getStyle(this.cascaderInner, 'width') !== menusWidth) { - setStyle(this.cascaderInner, 'width', menusWidth); - } - - if (getStyle(this.cascader, 'display') === 'inline-block') { - const hasRightBorderClass = `${this.props.prefix}has-right-border`; - menus.forEach(menu => removeClass(menu, hasRightBorderClass)); - if (this.cascader.clientWidth > menusWidth) { - addClass(menus[menus.length - 1], hasRightBorderClass); - } - } - } - - /*eslint-enable*/ - flatValue(value) { - return filterChildValue(value, this.state._v2n, this.state._p2n); - } - - getValue(pos) { - return this.state._p2n[pos] ? this.state._p2n[pos].value : null; - } - - getPos(value) { - return this.state._v2n[value] ? this.state._v2n[value].pos : null; - } - - getData(value) { - return value.map(v => this.state._v2n[v]); - } - - processValue(value, v, checked) { - const index = value.indexOf(v); - if (checked && index === -1) { - value.push(v); - } else if (!checked && index > -1) { - value.splice(index, 1); - } - } - - handleSelect(v, canExpand) { - if (!(this.props.canOnlySelectLeaf && canExpand)) { - const data = this.state._v2n[v]; - const nums = data.pos.split('-'); - const selectedPath = nums.slice(1).reduce((ret, num, index) => { - const p = nums.slice(0, index + 2).join('-'); - ret.push(this.state._p2n[p]); - return ret; - }, []); - - if (this.state.value[0] !== v) { - if (!('value' in this.props)) { - this.setState({ - value: [v], - }); - } - - if ('onChange' in this.props) { - this.props.onChange(v, data, { - selectedPath, - }); - } - } - - if ('onSelect' in this.props) { - this.props.onSelect(v, data, { - selectedPath, - }); - } - } - - if (canExpand) { - if (!this.props.canOnlySelectLeaf) { - this.lastExpandedValue = this.state.expandedValue.slice(0, -1); - } - } else { - this.lastExpandedValue = [...this.state.expandedValue]; - } - } - /*eslint-disable max-statements*/ - handleCheck(v, checked) { - const { checkStrictly, canOnlyCheckLeaf } = this.props; - const value = [...this.state.value]; - - if (checkStrictly || canOnlyCheckLeaf) { - this.processValue(value, v, checked); - } else { - const pos = this.getPos(v); - - const ps = Object.keys(this.state._p2n); - - forEachEnableNode(this.state._v2n[v], node => { - if (node.checkable === false) return; - this.processValue(value, node.value, checked); - }); - - let currentPos = pos; - const nums = pos.split('-'); - for (let i = nums.length; i > 2; i--) { - let parentCheck = true; - - const parentPos = nums.slice(0, i - 1).join('-'); - if ( - this.state._p2n[parentPos].disabled || - this.state._p2n[parentPos].checkboxDisabled || - this.state._p2n[parentPos].checkable === false - ) { - currentPos = parentPos; - continue; - } - - const parentValue = this.state._p2n[parentPos].value; - const parentChecked = value.indexOf(parentValue) > -1; - if (!checked && !parentChecked) { - break; - } - - for (let j = 0; j < ps.length; j++) { - const p = ps[j]; - const pnode = this.state._p2n[p]; - if (isSiblingOrSelf(currentPos, p) && !pnode.disabled && !pnode.checkboxDisabled) { - const k = pnode.value; - // eslint-disable-next-line max-depth - if (pnode.checkable === false) { - // eslint-disable-next-line max-depth - if (!pnode.children || pnode.children.length === 0) { - continue; - } - // eslint-disable-next-line max-depth - for (let m = 0; m < pnode.children.length; m++) { - // eslint-disable-next-line max-depth - if (!pnode.children.every(child => isNodeChecked(child, value))) { - parentCheck = false; - break; - } - } - } else if (value.indexOf(k) === -1) { - parentCheck = false; - } - - if (!parentCheck) break; - } - } - - this.processValue(value, parentValue, parentCheck); - - currentPos = parentPos; - } - } - - if (!('value' in this.props)) { - this.setState({ - value, - }); - } - - if ('onChange' in this.props) { - if (checkStrictly || canOnlyCheckLeaf) { - const data = this.getData(value); - this.props.onChange(value, data, { - checked, - currentData: this.state._v2n[v], - checkedData: data, - }); - } else { - const flatValue = this.flatValue(value); - const flatData = this.getData(flatValue); - const checkedData = this.getData(value); - const indeterminateValue = this.getIndeterminate(value); - const indeterminateData = this.getData(indeterminateValue); - this.props.onChange(flatValue, flatData, { - checked, - currentData: this.state._v2n[v], - checkedData, - indeterminateData, - }); - } - } - - this.lastExpandedValue = [...this.state.expandedValue]; - } - - handleExpand(value, level, canExpand, focusedFirstChild) { - const { expandedValue } = this.state; - - if (canExpand || expandedValue.length > level) { - if (canExpand) { - expandedValue.splice(level, expandedValue.length - level, value); - } else { - expandedValue.splice(level); - } - - const callback = () => { - this.setExpandValue(expandedValue, true); - - if (focusedFirstChild) { - const endExpandedValue = expandedValue[expandedValue.length - 1]; - this.setState({ - focusedValue: this.state._v2n[endExpandedValue].children[0].value, - }); - } - }; - - const { loadData } = this.props; - if (canExpand && loadData) { - const data = this.state._v2n[value]; - return loadData(data, data._source).then(callback); - } else { - return callback(); - } - } - } - - handleMouseLeave() { - this.setExpandValue([...this.lastExpandedValue], true); - } - - setExpandValue(expandedValue, isExpandedValueSetByAction = false) { - if (!('expandedValue' in this.props)) { - this.setState({ - expandedValue, - isExpandedValueSetByAction, - }); - } - - if ('onExpand' in this.props) { - this.props.onExpand(expandedValue); - } - } - - getFirstFocusKeyByDataSource(dataSource) { - if (!dataSource || dataSource.length === 0) { - return ''; - } - - for (let i = 0; i < dataSource.length; i++) { - if (dataSource[i] && !dataSource[i].disabled) { - return dataSource[i].value; - } - } - - return ''; - } - - getFirstFocusKeyByFilteredPaths(filteredPaths) { - if (!filteredPaths || filteredPaths.length === 0) { - return ''; - } - - for (let i = 0; i < filteredPaths.length; i++) { - const path = filteredPaths[i]; - if (!path.some(item => item.disabled)) { - const lastItem = path[path.length - 1]; - return lastItem.value; - } - } - - return ''; - } - - getFirstFocusKey() { - const { dataSource, searchValue, filteredPaths } = this.props; - - return !searchValue - ? this.getFirstFocusKeyByDataSource(dataSource) - : this.getFirstFocusKeyByFilteredPaths(filteredPaths); - } - - setFocusValue() { - this.setState({ - focusedValue: this.getFirstFocusKey(), - }); - } - - handleFocus(focusedValue) { - this.setState({ - focusedValue, - }); - } - - handleFold() { - const { expandedValue } = this.state; - if (expandedValue.length > 0) { - this.setExpandValue(expandedValue.slice(0, -1), true); - } - - this.setState({ - focusedValue: expandedValue[expandedValue.length - 1], - }); - } - - getIndeterminate(value) { - const indeterminateValues = []; - - const poss = filterChildValue( - value - .filter(v => !!this.state._v2n[v]) - .filter( - v => - !this.state._v2n[v].disabled && - !this.state._v2n[v].checkboxDisabled && - this.state._v2n[v].checkable !== false - ), - this.state._v2n, - this.state._p2n - ).map(v => this.state._v2n[v].pos); - poss.forEach(pos => { - const nums = pos.split('-'); - for (let i = nums.length; i > 2; i--) { - const parentPos = nums.slice(0, i - 1).join('-'); - const parent = this.state._p2n[parentPos]; - if (parent.disabled || parent.checkboxDisabled) break; - const parentValue = parent.value; - if (indeterminateValues.indexOf(parentValue) === -1) { - indeterminateValues.push(parentValue); - } - } - }); - - return indeterminateValues; - } - - onBlur(e) { - this.setState({ - focusedValue: undefined, - }); - - this.props.onBlur && this.props.onBlur(e); - } - - renderMenu(data, level) { - const { - prefix, - multiple, - useVirtual, - checkStrictly, - expandTriggerType, - loadData, - canOnlyCheckLeaf, - listClassName, - listStyle, - itemRender, - } = this.props; - const { value, expandedValue, focusedValue } = this.state; - - return ( - - {data - .map(item => { - const disabled = !!item.disabled; - const canExpand = (!!item.children && !!item.children.length) || (!!loadData && !item.isLeaf); - const expanded = expandedValue[level] === item.value; - const props = { - prefix, - disabled, - canExpand, - expanded, - expandTriggerType, - onExpand: this.handleExpand.bind(this, item.value, level, canExpand), - onFold: this.handleFold, - }; - - if ('title' in item) { - props.title = item.title; - } - - if (multiple) { - props.checkable = !(canOnlyCheckLeaf && canExpand); - props.checked = value.indexOf(item.value) > -1 || !!item.checked; - props.indeterminate = - (checkStrictly || canOnlyCheckLeaf - ? false - : this.indeterminate.indexOf(item.value) > -1) || !!item.indeterminate; - props.checkboxDisabled = !!item.checkboxDisabled; - props.onCheck = this.handleCheck.bind(this, item.value); - } else { - props.selected = value[0] === item.value; - props.onSelect = this.handleSelect.bind(this, item.value, canExpand); - } - - const itemContent = itemRender(item, props); - if (itemContent === null) { - return null; - } - return ( - - {itemContent} - - ); - }) - .filter(v => v)} - - ); - } - - renderMenus() { - const { dataSource } = this.props; - const { expandedValue } = this.state; - - const menus = []; - let data = dataSource; - - for (let i = 0; i <= expandedValue.length; i++) { - if (!data) { - break; - } - - menus.push(this.renderMenu(data, i)); - - let expandedItem; - for (let j = 0; j < data.length; j++) { - if (data[j].value === expandedValue[i]) { - expandedItem = data[j]; - break; - } - } - data = expandedItem ? expandedItem.children : null; - } - - return menus; - } - - renderFilteredItem(path) { - const { prefix, resultRender, searchValue, multiple } = this.props; - const { value } = this.state; - const lastItem = path[path.length - 1]; - - let Item; - const props = { - key: lastItem.value, - className: `${prefix}cascader-filtered-item`, - disabled: path.some(item => item.disabled), - children: resultRender(searchValue, path), - }; - - if (multiple) { - Item = Menu.CheckboxItem; - const { checkStrictly, canOnlyCheckLeaf } = this.props; - props.checked = value.indexOf(lastItem.value) > -1; - props.indeterminate = - !checkStrictly && !canOnlyCheckLeaf && this.indeterminate.indexOf(lastItem.value) > -1; - props.checkboxDisabled = lastItem.checkboxDisabled; - props.onChange = this.handleCheck.bind(this, lastItem.value); - } else { - Item = Menu.Item; - props.selected = value[0] === lastItem.value; - props.onSelect = this.handleSelect.bind(this, lastItem.value, false); - } - - return ; - } - - renderFilteredList() { - const { prefix, filteredListStyle, filteredPaths, focusable = false } = this.props; - const { focusedValue } = this.state; - return ( - - {filteredPaths.map(path => this.renderFilteredItem(path))} - - ); - } - - render() { - const { - prefix, - rtl, - className, - expandTriggerType, - multiple, - dataSource, - checkStrictly, - canOnlyCheckLeaf, - searchValue, - } = this.props; - const others = pickOthers(Object.keys(Cascader.propTypes), this.props); - const { value } = this.state; - - if (rtl) { - others.dir = 'rtl'; - } - - const props = { - className: cx({ - [`${prefix}cascader`]: true, - multiple, - [className]: !!className, - }), - ref: 'cascader', - ...others, - }; - if (expandTriggerType === 'hover') { - props.onMouseLeave = this.handleMouseLeave; - } - - if (multiple && !checkStrictly && !canOnlyCheckLeaf) { - this.indeterminate = this.getIndeterminate(value); - } - - return ( -
    - {!searchValue ? ( -
    - {dataSource && dataSource.length ? this.renderMenus() : null} -
    - ) : ( - this.renderFilteredList() - )} -
    - ); - } -} - -export default polyfill(Cascader); diff --git a/components/cascader/cascader.tsx b/components/cascader/cascader.tsx new file mode 100644 index 0000000000..11ca70a267 --- /dev/null +++ b/components/cascader/cascader.tsx @@ -0,0 +1,875 @@ +import React, { Component, type ReactElement, type FocusEvent, type ComponentType } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import cloneDeep from 'lodash.clonedeep'; +import cx from 'classnames'; +import Menu, { type ItemProps, type CheckboxItemProps, type MenuProps } from '../menu'; +import { func, obj, dom } from '../util'; +import CascaderMenu from './menu'; +import CascaderMenuItem from './item'; +import { + filterChildValue, + getAllCheckedValues, + forEachEnableNode, + isSiblingOrSelf, + isDescendantOrSelf, + isNodeChecked, +} from './utils'; +import type { + CascaderDataItem, + CascaderDataItemWithPosInfo, + ItemProps as CascaderItemProps, + CascaderProps, + CascaderState, + NormalizeValueReturns, + P2n, + V2n, +} from './types'; +import { Menu as ViewMenu } from '../menu/view/menu'; +import ConfigProvider from '../config-provider'; + +const { bindCtx } = func; +const { pickOthers, pickProps } = obj; +const { addClass, removeClass, setStyle, getStyle } = dom; + +// 数据打平 +const flatDataSource = ( + data: Array, + prefix = '0', + v2n: V2n = {}, + p2n: P2n = {} +) => { + data.forEach((item, index) => { + const { value, children } = item; + const pos = `${prefix}-${index}`; + const newValue = String(value); + + item.value = newValue; + + v2n[newValue] = p2n[pos] = { + ...item, + pos, + _source: item, + }; + + if (children && children.length) { + flatDataSource(children, pos, v2n, p2n); + } + }); + + return { v2n, p2n }; +}; + +function preHandleData(data: Array, immutable?: boolean) { + const _data = immutable ? cloneDeep(data) : data; + + try { + return flatDataSource(_data); + } catch (err) { + if ((err.message || '').match('Cannot assign to read only property')) { + // eslint-disable-next-line no-console + console.error( + err.message, + 'try to set immutable to true to allow immutable dataSource' + ); + } + throw err; + } +} + +const getExpandedValue = (v: string | undefined, _v2n: V2n, _p2n: V2n) => { + if (!v || !_v2n[v]) { + return []; + } + + const pos = _v2n[v].pos; + if (pos.split('-').length === 2) { + return []; + } + + const expandedMap: Record = {}; + Object.keys(_p2n).forEach(p => { + if (isDescendantOrSelf(p, pos) && p !== pos) { + expandedMap[_p2n[p].value] = p; + } + }); + + return Object.keys(expandedMap).sort((prev, next) => { + return expandedMap[prev].split('-').length - expandedMap[next].split('-').length; + }); +}; + +const normalizeValue = (value: T): NormalizeValueReturns => { + if (value) { + if (Array.isArray(value)) { + return value as NormalizeValueReturns; + } + + return [value] as NormalizeValueReturns; + } + + return [] as NormalizeValueReturns; +}; + +const getFormatMenuProps = (others: Record) => { + const targetProps = pickProps(ViewMenu.propTypes, others); + const targetMenuProps = pickOthers( + { + value: false, + onChange: false, + defaultValue: false, + focusedKey: false, + onItemFocus: false, + focusable: false, + onBlur: false, + ...ConfigProvider.propTypes, + }, + targetProps + ); + return { + ...targetMenuProps, + isSelectIconRight: false, + } as MenuProps; +}; + +/** + * Cascader + */ +class Cascader extends Component { + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + pure: PropTypes.bool, + className: PropTypes.string, + dataSource: PropTypes.arrayOf(PropTypes.object), + defaultValue: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + value: PropTypes.oneOfType([PropTypes.string, PropTypes.arrayOf(PropTypes.string)]), + onChange: PropTypes.func, + onSelect: PropTypes.func, + defaultExpandedValue: PropTypes.arrayOf(PropTypes.string), + expandedValue: PropTypes.arrayOf(PropTypes.string), + expandTriggerType: PropTypes.oneOf(['click', 'hover']), + onExpand: PropTypes.func, + useVirtual: PropTypes.bool, + multiple: PropTypes.bool, + canOnlySelectLeaf: PropTypes.bool, + canOnlyCheckLeaf: PropTypes.bool, + checkStrictly: PropTypes.bool, + listStyle: PropTypes.object, + listClassName: PropTypes.string, + itemRender: PropTypes.func, + loadData: PropTypes.func, + searchValue: PropTypes.string, + onBlur: PropTypes.func, + filteredPaths: PropTypes.array, + filteredListStyle: PropTypes.object, + resultRender: PropTypes.func, + immutable: PropTypes.bool, + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + pure: false, + dataSource: [], + defaultValue: null, + canOnlySelectLeaf: false, + canOnlyCheckLeaf: false, + expandTriggerType: 'click', + multiple: false, + useVirtual: false, + checkStrictly: false, + itemRender: (item: CascaderDataItem) => item.label, + immutable: false, + }; + + lastExpandedValue: string[]; + cascader: HTMLDivElement; + cascaderInner: HTMLDivElement; + indeterminate: string[]; + + constructor(props: CascaderProps) { + super(props); + + const { + defaultValue, + value, + defaultExpandedValue, + expandedValue, + dataSource, + multiple, + checkStrictly, + canOnlyCheckLeaf, + loadData, + immutable, + } = props; + + const { v2n, p2n } = preHandleData(dataSource!, immutable); + + let normalizedValue = normalizeValue(typeof value === 'undefined' ? defaultValue : value); + + if (!loadData) { + normalizedValue = normalizedValue.filter(v => v2n[v]); + } + + const realExpandedValue = + typeof expandedValue === 'undefined' + ? typeof defaultExpandedValue === 'undefined' + ? getExpandedValue(normalizedValue[0], v2n, p2n) + : normalizeValue(defaultExpandedValue) + : normalizeValue(expandedValue); + const st = { + value: normalizedValue, + expandedValue: realExpandedValue, + isExpandedValueSetByAction: false, + }; + if (multiple && !checkStrictly && !canOnlyCheckLeaf) { + st.value = getAllCheckedValues(st.value, v2n, p2n); + } + + this.lastExpandedValue = [...st.expandedValue]; + this.state = { + ...st, + _v2n: v2n, + _p2n: p2n, + }; + + bindCtx(this, [ + 'handleMouseLeave', + 'handleFocus', + 'handleFold', + 'getCascaderNode', + 'getCascaderInnerNode', + 'onBlur', + ]); + } + + static getDerivedStateFromProps(props: CascaderProps, state: CascaderState) { + const { v2n, p2n } = preHandleData(props.dataSource!, props.immutable); + const states: Partial = {}; + + if ('value' in props) { + states.value = normalizeValue(props.value); + if (!props.loadData) { + states.value = states.value.filter(v => v2n[v]); + } + + const { multiple, checkStrictly, canOnlyCheckLeaf } = props; + if (multiple && !checkStrictly && !canOnlyCheckLeaf) { + states.value = getAllCheckedValues(states.value, v2n, p2n); + } + + if ( + // 非受控模式下,若已经通过用户事件调整了 expandedValue,则忽略下面的空值兜底处理 + !state.isExpandedValueSetByAction && + !state.expandedValue.length && + !('expandedValue' in props) + ) { + states.expandedValue = getExpandedValue(states.value[0], v2n, p2n); + } + } + + if ('expandedValue' in props) { + states.expandedValue = normalizeValue(props.expandedValue); + // 受控模式则重置 isExpandedValueSetByAction + states.isExpandedValueSetByAction = false; + } + + return { + ...states, + _v2n: v2n, + _p2n: p2n, + }; + } + componentDidMount() { + this.setCascaderInnerWidth(); + } + + componentDidUpdate() { + this.setCascaderInnerWidth(); + } + + getCascaderNode(ref: HTMLDivElement) { + this.cascader = ref; + } + + getCascaderInnerNode(ref: HTMLDivElement) { + this.cascaderInner = ref; + } + + setCascaderInnerWidth() { + if (!this.cascaderInner) { + return; + } + const menus: HTMLElement[] = [].slice.call( + this.cascaderInner.querySelectorAll(`.${this.props.prefix}cascader-menu-wrapper`) + ); + if (menus.length === 0) { + return; + } + + const menusWidth = Math.ceil( + menus.reduce((ret, menu) => { + return ret + Math.ceil(menu.getBoundingClientRect().width); + }, 0) + ); + + if (getStyle(this.cascaderInner, 'width') !== menusWidth) { + setStyle(this.cascaderInner, 'width', menusWidth); + } + + if (getStyle(this.cascader, 'display') === 'inline-block') { + const hasRightBorderClass = `${this.props.prefix}has-right-border`; + menus.forEach(menu => removeClass(menu, hasRightBorderClass)); + if (this.cascader.clientWidth > menusWidth) { + addClass(menus[menus.length - 1], hasRightBorderClass); + } + } + } + + flatValue(value: string[]) { + return filterChildValue(value, this.state._v2n, this.state._p2n); + } + + getValue(pos: string) { + return this.state._p2n[pos] ? this.state._p2n[pos].value : null; + } + + getPos(value: string) { + return this.state._v2n[value] ? this.state._v2n[value].pos : null; + } + + getData(value: string[]) { + return value.map(v => this.state._v2n[v]); + } + + processValue(value: string[], v: string, checked: boolean) { + const index = value.indexOf(v); + if (checked && index === -1) { + value.push(v); + } else if (!checked && index > -1) { + value.splice(index, 1); + } + } + + handleSelect(v: string, canExpand: boolean) { + if (!(this.props.canOnlySelectLeaf && canExpand)) { + const data = this.state._v2n[v]; + const nums = data.pos.split('-'); + const selectedPath = nums.slice(1).reduce((ret, num, index) => { + const p = nums.slice(0, index + 2).join('-'); + ret.push(this.state._p2n[p]); + return ret; + }, [] as CascaderDataItemWithPosInfo[]); + + if (this.state.value[0] !== v) { + if (!('value' in this.props)) { + this.setState({ + value: [v], + }); + } + + if ('onChange' in this.props) { + this.props.onChange!(v, data, { + selectedPath, + }); + } + } + + if ('onSelect' in this.props) { + this.props.onSelect!(v, data, { + selectedPath, + }); + } + } + + if (canExpand) { + if (!this.props.canOnlySelectLeaf) { + this.lastExpandedValue = this.state.expandedValue.slice(0, -1); + } + } else { + this.lastExpandedValue = [...this.state.expandedValue]; + } + } + handleCheck(v: string, checked: boolean) { + const { checkStrictly, canOnlyCheckLeaf } = this.props; + const value = [...this.state.value]; + + if (checkStrictly || canOnlyCheckLeaf) { + this.processValue(value, v, checked); + } else { + const pos = this.getPos(v); + + const ps = Object.keys(this.state._p2n); + + forEachEnableNode(this.state._v2n[v], node => { + if (node.checkable === false) return; + this.processValue(value, node.value, checked); + }); + + let currentPos = pos; + const nums = pos!.split('-'); + for (let i = nums.length; i > 2; i--) { + let parentCheck = true; + + const parentPos = nums.slice(0, i - 1).join('-'); + if ( + this.state._p2n[parentPos].disabled || + this.state._p2n[parentPos].checkboxDisabled || + this.state._p2n[parentPos].checkable === false + ) { + currentPos = parentPos; + continue; + } + + const parentValue = this.state._p2n[parentPos].value; + const parentChecked = value.indexOf(parentValue) > -1; + if (!checked && !parentChecked) { + break; + } + + for (let j = 0; j < ps.length; j++) { + const p = ps[j]; + const pnode = this.state._p2n[p]; + if ( + isSiblingOrSelf(currentPos!, p) && + !pnode.disabled && + !pnode.checkboxDisabled + ) { + const k = pnode.value; + if (pnode.checkable === false) { + if (!pnode.children || pnode.children.length === 0) { + continue; + } + for (let m = 0; m < pnode.children.length; m++) { + if (!pnode.children.every(child => isNodeChecked(child, value))) { + parentCheck = false; + break; + } + } + } else if (value.indexOf(k) === -1) { + parentCheck = false; + } + + if (!parentCheck) break; + } + } + + this.processValue(value, parentValue, parentCheck); + + currentPos = parentPos; + } + } + + if (!('value' in this.props)) { + this.setState({ + value, + }); + } + + if ('onChange' in this.props) { + if (checkStrictly || canOnlyCheckLeaf) { + const data = this.getData(value); + this.props.onChange!(value, data, { + checked, + currentData: this.state._v2n[v], + checkedData: data, + }); + } else { + const flatValue = this.flatValue(value); + const flatData = this.getData(flatValue); + const checkedData = this.getData(value); + const indeterminateValue = this.getIndeterminate(value); + const indeterminateData = this.getData(indeterminateValue); + this.props.onChange!(flatValue, flatData, { + checked, + currentData: this.state._v2n[v], + checkedData, + indeterminateData, + }); + } + } + + this.lastExpandedValue = [...this.state.expandedValue]; + } + + handleExpand(value: string, level: number, canExpand: boolean, focusedFirstChild: boolean) { + const { expandedValue } = this.state; + + if (canExpand || expandedValue.length > level) { + // FIXME 此处实现有 bug,state.expandedValue 被直接修改,并没有考虑受控非受控的情况 + if (canExpand) { + expandedValue.splice(level, expandedValue.length - level, value); + } else { + expandedValue.splice(level); + } + + const callback = () => { + this.setExpandValue(expandedValue, true); + + if (focusedFirstChild) { + const endExpandedValue = expandedValue[expandedValue.length - 1]; + this.setState({ + focusedValue: this.state._v2n[endExpandedValue].children![0].value, + }); + } + }; + + const { loadData } = this.props; + if (canExpand && loadData) { + const data = this.state._v2n[value]; + return loadData(data, data._source!).then(callback); + } else { + return callback(); + } + } + } + + handleMouseLeave() { + this.setExpandValue([...this.lastExpandedValue], true); + } + + setExpandValue(expandedValue: string[], isExpandedValueSetByAction = false) { + if (!('expandedValue' in this.props)) { + this.setState({ + expandedValue, + isExpandedValueSetByAction, + }); + } + + if ('onExpand' in this.props) { + this.props.onExpand!(expandedValue); + } + } + + getFirstFocusKeyByDataSource(dataSource: Array) { + if (!dataSource || dataSource.length === 0) { + return ''; + } + + for (let i = 0; i < dataSource.length; i++) { + if (dataSource[i] && !dataSource[i].disabled) { + return dataSource[i].value; + } + } + + return ''; + } + + getFirstFocusKeyByFilteredPaths(filteredPaths: CascaderProps['filteredPaths']) { + if (!filteredPaths || filteredPaths.length === 0) { + return ''; + } + + for (let i = 0; i < filteredPaths.length; i++) { + const path = filteredPaths[i]; + if (!path.some(item => item.disabled)) { + const lastItem = path[path.length - 1]; + return lastItem.value; + } + } + + return ''; + } + + getFirstFocusKey() { + const { dataSource, searchValue, filteredPaths } = this.props; + + return !searchValue + ? this.getFirstFocusKeyByDataSource(dataSource!) + : this.getFirstFocusKeyByFilteredPaths(filteredPaths); + } + + setFocusValue() { + this.setState({ + focusedValue: this.getFirstFocusKey(), + }); + } + + handleFocus(focusedValue: string) { + this.setState({ + focusedValue, + }); + } + + handleFold() { + const { expandedValue } = this.state; + if (expandedValue.length > 0) { + this.setExpandValue(expandedValue.slice(0, -1), true); + } + + this.setState({ + focusedValue: expandedValue[expandedValue.length - 1], + }); + } + + getIndeterminate(value: string[]) { + const indeterminateValues: string[] = []; + + const poss = filterChildValue( + value + .filter(v => !!this.state._v2n[v]) + .filter( + v => + !this.state._v2n[v].disabled && + !this.state._v2n[v].checkboxDisabled && + this.state._v2n[v].checkable !== false + ), + this.state._v2n, + this.state._p2n + ).map(v => this.state._v2n[v].pos); + poss.forEach(pos => { + const nums = pos.split('-'); + for (let i = nums.length; i > 2; i--) { + const parentPos = nums.slice(0, i - 1).join('-'); + const parent = this.state._p2n[parentPos]; + if (parent.disabled || parent.checkboxDisabled) break; + const parentValue = parent.value; + if (indeterminateValues.indexOf(parentValue) === -1) { + indeterminateValues.push(parentValue); + } + } + }); + + return indeterminateValues; + } + + onBlur(e: FocusEvent) { + this.setState({ + focusedValue: undefined, + }); + + this.props.onBlur && this.props.onBlur(e); + } + + renderMenu(data: CascaderProps['dataSource'], level: number) { + const { + prefix, + multiple, + useVirtual, + checkStrictly, + expandTriggerType, + loadData, + canOnlyCheckLeaf, + listClassName, + listStyle, + itemRender, + ...others + } = this.props; + const { value, expandedValue, focusedValue } = this.state; + + return ( + + { + data! + .map(item => { + const disabled = !!item.disabled; + const canExpand = + (!!item.children && !!item.children.length) || + (!!loadData && !item.isLeaf); + const expanded = expandedValue[level] === item.value; + const props: CascaderItemProps = { + prefix, + disabled, + canExpand, + expanded, + expandTriggerType, + onExpand: this.handleExpand.bind( + this, + item.value, + level, + canExpand + ), + onFold: this.handleFold, + }; + + if ('title' in item) { + props.title = item.title; + } + + if (multiple) { + props.checkable = !(canOnlyCheckLeaf && canExpand); + props.checked = value.indexOf(item.value) > -1 || !!item.checked; + props.indeterminate = + (checkStrictly || canOnlyCheckLeaf + ? false + : this.indeterminate.indexOf(item.value) > -1) || + !!item.indeterminate; + props.checkboxDisabled = !!item.checkboxDisabled; + props.onCheck = this.handleCheck.bind(this, item.value); + } else { + props.selected = value[0] === item.value; + props.onSelect = this.handleSelect.bind( + this, + item.value, + canExpand + ); + } + + const itemContent = itemRender!(item, props); + if (itemContent === null) { + return null; + } + return ( + + {itemContent} + + ); + }) + .filter(v => v) as ReactElement[] + } + + ); + } + + renderMenus() { + const { dataSource } = this.props; + const { expandedValue } = this.state; + + const menus = []; + let data: CascaderProps['dataSource'] | null | undefined = dataSource; + + for (let i = 0; i <= expandedValue.length; i++) { + if (!data) { + break; + } + + menus.push(this.renderMenu(data, i)); + + let expandedItem; + for (let j = 0; j < data.length; j++) { + if (data[j].value === expandedValue[i]) { + expandedItem = data[j]; + break; + } + } + data = expandedItem ? expandedItem.children : null; + } + + return menus; + } + + renderFilteredItem(path: CascaderDataItemWithPosInfo[]) { + const { prefix, resultRender, searchValue, multiple } = this.props; + const { value } = this.state; + const lastItem = path[path.length - 1]; + + let Item: ComponentType; + let props: CheckboxItemProps | ItemProps = { + className: `${prefix}cascader-filtered-item`, + disabled: path.some(item => item.disabled), + children: resultRender!(searchValue!, path), + }; + + if (multiple) { + Item = Menu.CheckboxItem; + const { checkStrictly, canOnlyCheckLeaf } = this.props; + props = { + ...props, + checked: value.indexOf(lastItem.value) > -1, + indeterminate: + !checkStrictly && + !canOnlyCheckLeaf && + this.indeterminate.indexOf(lastItem.value) > -1, + checkboxDisabled: lastItem.checkboxDisabled, + onChange: this.handleCheck.bind(this, lastItem.value), + }; + } else { + Item = Menu.Item as ComponentType; + props = { + ...props, + selected: value[0] === lastItem.value, + onSelect: this.handleSelect.bind(this, lastItem.value, false), + }; + } + return ; + } + + renderFilteredList() { + const { + prefix, + filteredListStyle, + filteredPaths, + focusable = false, + ...others + } = this.props; + const { focusedValue } = this.state; + + return ( + + {filteredPaths!.map(path => this.renderFilteredItem(path))} + + ); + } + + render() { + const { + prefix, + rtl, + className, + expandTriggerType, + multiple, + dataSource, + checkStrictly, + canOnlyCheckLeaf, + searchValue, + } = this.props; + // FIXME 这样做风险比较大,propTypes 如果不全,就会出现一些 div 接收不了的参数传导到 div + const others = pickOthers({ ...Cascader.propTypes, ...ViewMenu.propTypes }, this.props); + const { value } = this.state; + + if (rtl) { + others.dir = 'rtl'; + } + + const props = { + className: cx({ + [`${prefix}cascader`]: true, + multiple, + [className!]: !!className, + }), + ref: 'cascader', + ...others, + }; + if (expandTriggerType === 'hover') { + props.onMouseLeave = this.handleMouseLeave; + } + + if (multiple && !checkStrictly && !canOnlyCheckLeaf) { + this.indeterminate = this.getIndeterminate(value); + } + + return ( +
    + {!searchValue ? ( +
    + {dataSource && dataSource.length ? this.renderMenus() : null} +
    + ) : ( + this.renderFilteredList() + )} +
    + ); + } +} + +export default polyfill(Cascader); diff --git a/components/cascader/index.d.ts b/components/cascader/index.d.ts deleted file mode 100644 index c0a5df6cea..0000000000 --- a/components/cascader/index.d.ts +++ /dev/null @@ -1,135 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -type data = { - value?: string; - label?: string; - disabled?: boolean; - checkboxDisabled?: boolean; - children?: Array; - [propName: string]: any; -}; - -type extra = { - /** - * 单选时选中的数据的路径 - */ - selectedPath?: Array; - /** - * 多选时当前的操作是选中还是取消选中 - */ - checked?: boolean; - /** - * 多选时当前操作的数据 - */ - currentData?: any; - /** - * 多选时所有被选中的数据 - */ - checkedData?: Array; - /** - * 多选时半选的数据 - */ - indeterminateData?: Array; -}; - -export interface CascaderProps extends HTMLAttributesWeak, CommonProps { - /** - * 数据源,结构可参考下方说明 - */ - dataSource?: Array; - - /** - * (非受控)默认值 - */ - defaultValue?: string | Array; - - /** - * (受控)当前值 - */ - value?: string | Array; - - /** - * 选中值改变时触发的回调函数 - */ - onChange?: (value: string | Array, data: data | Array, extra: extra) => void; - - /** - * (非受控)默认展开值,如果不设置,组件内部会根据 defaultValue/value 进行自动设置 - */ - defaultExpandedValue?: Array; - - /** - * (受控)当前展开值 - */ - expandedValue?: Array; - - /** - * 展开触发的方式 - */ - expandTriggerType?: 'click' | 'hover'; - - /** - * 展开时触发的回调函数 - */ - onExpand?: (expandedValue: Array) => void; - - /** - * 是否开启虚拟滚动 - */ - useVirtual?: boolean; - - /** - * 是否多选 - */ - multiple?: boolean; - - /** - * 单选时是否只能选中叶子节点 - */ - canOnlySelectLeaf?: boolean; - - /** - * 多选时是否只能选中叶子节点 - */ - canOnlyCheckLeaf?: boolean; - - /** - * 父子节点是否选中不关联 - */ - checkStrictly?: boolean; - - /** - * 每列列表样式对象 - */ - listStyle?: React.CSSProperties; - - /** - * 每列列表类名 - */ - listClassName?: string; - - /** - * 每列列表项渲染函数 - */ - itemRender?: (data: data) => React.ReactNode; - - /** - * 异步加载数据函数,source是原始对象 - */ - loadData?: (data: data, source: data) => void; - - /** - * 是否是不可变数据 - */ - immutable?: boolean; -} - -export default class Cascader extends React.Component {} diff --git a/components/cascader/index.jsx b/components/cascader/index.jsx deleted file mode 100644 index bcdc73a243..0000000000 --- a/components/cascader/index.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import ConfigProvider from '../config-provider'; -import Cascader from './cascader'; - -export default ConfigProvider.config(Cascader, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('expandTrigger' in props) { - deprecated('expandTrigger', 'expandTriggerType', 'Cascader'); - const { expandTrigger, ...others } = props; - props = { expandTriggerType: expandTrigger, ...others }; - } - - if ('showItemCount' in props) { - deprecated('showItemCount', 'listStyle | listClassName', 'Cascader'); - } - if ('labelWidth' in props) { - deprecated('labelWidth', 'listStyle | listClassName', 'Cascader'); - } - - return props; - }, - exportNames: ['setFocusValue'], -}); diff --git a/components/cascader/index.tsx b/components/cascader/index.tsx new file mode 100644 index 0000000000..520630c486 --- /dev/null +++ b/components/cascader/index.tsx @@ -0,0 +1,24 @@ +import ConfigProvider from '../config-provider'; +import Cascader from './cascader'; + +export type { CascaderProps, CascaderDataItem, CascaderDataItemWithPosInfo, Extra } from './types'; + +export default ConfigProvider.config(Cascader, { + transform: (props, deprecated) => { + if ('expandTrigger' in props) { + deprecated('expandTrigger', 'expandTriggerType', 'Cascader'); + const { expandTrigger, ...others } = props; + props = { expandTriggerType: expandTrigger, ...others }; + } + + if ('showItemCount' in props) { + deprecated('showItemCount', 'listStyle | listClassName', 'Cascader'); + } + if ('labelWidth' in props) { + deprecated('labelWidth', 'listStyle | listClassName', 'Cascader'); + } + + return props; + }, + exportNames: ['setFocusValue'], +}); diff --git a/components/cascader/item.jsx b/components/cascader/item.jsx deleted file mode 100644 index 06139076dd..0000000000 --- a/components/cascader/item.jsx +++ /dev/null @@ -1,166 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import Menu from '../menu'; -import Icon from '../icon'; -import { func, obj, KEYCODE } from '../util'; - -const { bindCtx } = func; -const { pickOthers } = obj; - -export default class CascaderMenuItem extends Component { - static menuChildType = 'item'; - - static propTypes = { - prefix: PropTypes.string, - className: PropTypes.string, - disabled: PropTypes.bool, - selected: PropTypes.bool, - onSelect: PropTypes.func, - expanded: PropTypes.bool, - canExpand: PropTypes.bool, - menu: PropTypes.any, - expandTriggerType: PropTypes.oneOf(['click', 'hover']), - onExpand: PropTypes.func, - onFold: PropTypes.func, - checkable: PropTypes.bool, - checked: PropTypes.bool, - indeterminate: PropTypes.bool, - checkboxDisabled: PropTypes.bool, - onCheck: PropTypes.func, - children: PropTypes.node, - }; - - constructor(props) { - super(props); - - this.state = { - loading: false, - }; - - bindCtx(this, ['handleExpand', 'handleClick', 'handleMouseEnter', 'handleKeyDown', 'removeLoading']); - } - - addLoading() { - this.setState({ - loading: true, - }); - } - - removeLoading() { - this.setState({ - loading: false, - }); - } - - setLoadingIfNeed(p) { - if (p && typeof p.then === 'function') { - this.addLoading(); - p.then(this.removeLoading).catch(this.removeLoading); - } - } - - handleExpand(focusedFirstChild) { - this.setLoadingIfNeed(this.props.onExpand(focusedFirstChild)); - } - - handleClick() { - this.handleExpand(false); - } - - handleMouseEnter() { - this.handleExpand(false); - } - - handleKeyDown(e) { - if (!this.props.disabled) { - if (e.keyCode === KEYCODE.RIGHT || e.keyCode === KEYCODE.ENTER) { - if (this.props.canExpand) { - this.handleExpand(true); - } - } else if (e.keyCode === KEYCODE.LEFT || e.keyCode === KEYCODE.ESC) { - this.props.onFold(); - } else if (e.keyCode === KEYCODE.SPACE) { - this.handleExpand(false); - } - } - } - - render() { - const { - prefix, - className, - menu, - disabled, - selected, - onSelect, - expanded, - canExpand, - expandTriggerType, - checkable, - checked, - indeterminate, - checkboxDisabled, - onCheck, - children, - } = this.props; - const others = pickOthers(Object.keys(CascaderMenuItem.propTypes), this.props); - const { loading } = this.state; - - const itemProps = { - className: cx({ - [`${prefix}cascader-menu-item`]: true, - [`${prefix}expanded`]: expanded, - [className]: !!className, - }), - disabled, - menu, - onKeyDown: this.handleKeyDown, - role: 'option', - ...others, - }; - if (!disabled) { - if (expandTriggerType === 'hover') { - itemProps.onMouseEnter = this.handleMouseEnter; - } else { - itemProps.onClick = this.handleClick; - } - } - - let Item, title; - if (checkable) { - Item = Menu.CheckboxItem; - itemProps.checked = checked; - itemProps.indeterminate = indeterminate; - itemProps.checkboxDisabled = checkboxDisabled; - itemProps.onChange = onCheck; - } else { - Item = Menu.Item; - itemProps.selected = selected; - itemProps.onSelect = onSelect; - } - - if (typeof children === 'string') { - title = children; - } - - return ( - - {children} - {canExpand ? ( - loading ? ( - - ) : ( - - ) - ) : null} - - ); - } -} diff --git a/components/cascader/item.tsx b/components/cascader/item.tsx new file mode 100644 index 0000000000..3ad8c351b2 --- /dev/null +++ b/components/cascader/item.tsx @@ -0,0 +1,173 @@ +import React, { Component, type ComponentType, type KeyboardEvent } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Menu, { type CheckboxItemProps, type ItemProps as MenuItemProps } from '../menu'; +import Icon from '../icon'; +import { func, obj, KEYCODE } from '../util'; +import type { ItemProps, ItemState } from './types'; + +const { bindCtx } = func; +const { pickOthers } = obj; + +export default class CascaderMenuItem extends Component { + static menuChildType = 'item'; + + static propTypes = { + prefix: PropTypes.string, + className: PropTypes.string, + disabled: PropTypes.bool, + selected: PropTypes.bool, + onSelect: PropTypes.func, + expanded: PropTypes.bool, + canExpand: PropTypes.bool, + menu: PropTypes.any, + expandTriggerType: PropTypes.oneOf(['click', 'hover']), + onExpand: PropTypes.func, + onFold: PropTypes.func, + checkable: PropTypes.bool, + checked: PropTypes.bool, + indeterminate: PropTypes.bool, + checkboxDisabled: PropTypes.bool, + onCheck: PropTypes.func, + children: PropTypes.node, + }; + + constructor(props: ItemProps) { + super(props); + + this.state = { + loading: false, + }; + + bindCtx(this, [ + 'handleExpand', + 'handleClick', + 'handleMouseEnter', + 'handleKeyDown', + 'removeLoading', + ]); + } + + addLoading() { + this.setState({ + loading: true, + }); + } + + removeLoading() { + this.setState({ + loading: false, + }); + } + + setLoadingIfNeed(p?: Promise | void) { + if (p && typeof p.then === 'function') { + this.addLoading(); + p.then(this.removeLoading).catch(this.removeLoading); + } + } + + handleExpand(focusedFirstChild: boolean) { + this.setLoadingIfNeed(this.props.onExpand!(focusedFirstChild)); + } + + handleClick() { + this.handleExpand(false); + } + + handleMouseEnter() { + this.handleExpand(false); + } + + handleKeyDown(e: KeyboardEvent) { + if (!this.props.disabled) { + if (e.keyCode === KEYCODE.RIGHT || e.keyCode === KEYCODE.ENTER) { + if (this.props.canExpand) { + this.handleExpand(true); + } + } else if (e.keyCode === KEYCODE.LEFT || e.keyCode === KEYCODE.ESC) { + this.props.onFold!(); + } else if (e.keyCode === KEYCODE.SPACE) { + this.handleExpand(false); + } + } + } + + render() { + const { + prefix, + className, + menu, + disabled, + selected, + onSelect, + expanded, + canExpand, + expandTriggerType, + checkable, + checked, + indeterminate, + checkboxDisabled, + onCheck, + children, + } = this.props; + const others = pickOthers(CascaderMenuItem.propTypes, this.props); + const { loading } = this.state; + + const itemProps: CheckboxItemProps | MenuItemProps = { + className: cx({ + [`${prefix}cascader-menu-item`]: true, + [`${prefix}expanded`]: expanded, + [className!]: !!className, + }), + disabled, + menu, + onKeyDown: this.handleKeyDown, + role: 'option', + ...others, + }; + if (!disabled) { + if (expandTriggerType === 'hover') { + itemProps.onMouseEnter = this.handleMouseEnter; + } else { + itemProps.onClick = this.handleClick; + } + } + + let Item: ComponentType, title; + if (checkable) { + Item = Menu.CheckboxItem; + (itemProps as CheckboxItemProps).checked = checked; + (itemProps as CheckboxItemProps).indeterminate = indeterminate; + (itemProps as CheckboxItemProps).checkboxDisabled = checkboxDisabled; + (itemProps as CheckboxItemProps).onChange = onCheck; + } else { + Item = Menu.Item as ComponentType; + itemProps.selected = selected; + itemProps.onSelect = onSelect; + } + + if (typeof children === 'string') { + title = children; + } + + return ( + + {children} + {canExpand ? ( + loading ? ( + + ) : ( + + ) + ) : null} + + ); + } +} diff --git a/components/cascader/menu.jsx b/components/cascader/menu.jsx deleted file mode 100644 index 24abbbf34d..0000000000 --- a/components/cascader/menu.jsx +++ /dev/null @@ -1,97 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { findDOMNode } from 'react-dom'; -import Menu from '../menu'; -import VirtualList from '../virtual-list'; - -export default class CascaderMenu extends Component { - static propTypes = { - prefix: PropTypes.string, - className: PropTypes.string, - useVirtual: PropTypes.bool, - children: PropTypes.node, - }; - - componentDidMount() { - this.scrollToSelectedItem(); - } - - scrollToSelectedItem() { - const { prefix, useVirtual, children } = this.props; - if (!children || children.length === 0) { - return; - } - const selectedIndex = children.findIndex( - item => !!item.props.checked || !!item.props.selected || !!item.props.expanded - ); - - if (selectedIndex === -1) { - return; - } - - if (useVirtual) { - const instance = this.virtualEl.getInstance(); - setTimeout(() => instance.scrollTo(selectedIndex), 0); - } else { - const itemSelector = `.${prefix}menu-item`; - const menu = findDOMNode(this.menuEl); - const targetItem = menu.querySelectorAll(itemSelector)[selectedIndex]; - if (targetItem) { - menu.scrollTop = - targetItem.offsetTop - - Math.floor((menu.clientHeight / targetItem.clientHeight - 1) / 2) * targetItem.clientHeight; - } - } - } - - renderMenu(items, ref, props) { - return ( - - {items.map(node => { - if (React.isValidElement(node) && node.type.menuChildType === 'item') { - return React.cloneElement(node, { - menu: this, - }); - } - - return node; - })} - - ); - } - - saveMenuRef = ref => { - this.menuEl = ref; - }; - - saveVirtualRef = ref => { - this.virtualEl = ref; - }; - - render() { - const { prefix, useVirtual, className, style, children, ...others } = this.props; - const menuProps = { - labelToggleChecked: false, - className: `${prefix}cascader-menu`, - ...others, - }; - return ( -
    - {useVirtual ? ( - this.renderMenu(items, ref, menuProps)} - > - {children} - - ) : ( - this.renderMenu(children, undefined, menuProps) - )} -
    - ); - } -} diff --git a/components/cascader/menu.tsx b/components/cascader/menu.tsx new file mode 100644 index 0000000000..74ffafdf08 --- /dev/null +++ b/components/cascader/menu.tsx @@ -0,0 +1,124 @@ +import React, { + Component, + type ReactNode, + type LegacyRef, + type ReactNodeArray, + type ComponentElement, + type ComponentRef, +} from 'react'; +import PropTypes from 'prop-types'; +import { findDOMNode } from 'react-dom'; +import Menu, { type MenuProps } from '../menu'; +import VirtualList from '../virtual-list'; +import type { CascaderMenuProps, ItemProps } from './types'; +import CascaderMenuItem from './item'; + +export default class CascaderMenu extends Component { + static propTypes = { + prefix: PropTypes.string, + className: PropTypes.string, + useVirtual: PropTypes.bool, + children: PropTypes.node, + }; + virtualEl: InstanceType | null; + menuEl: HTMLDivElement; + + componentDidMount() { + this.scrollToSelectedItem(); + } + + scrollToSelectedItem() { + const { prefix, useVirtual, children } = this.props; + // FIXME 这里的判断很容易报错 + if (!children || (children as ReactNodeArray).length === 0) { + return; + } + const selectedIndex = children.findIndex( + item => !!item.props.checked || !!item.props.selected || !!item.props.expanded + ); + + if (selectedIndex === -1) { + return; + } + + if (useVirtual) { + const instance = this.virtualEl!.getInstance(); + setTimeout(() => instance.scrollTo(selectedIndex), 0); + } else { + const itemSelector = `.${prefix}menu-item`; + const menu = findDOMNode(this.menuEl) as HTMLElement; + const targetItem = menu.querySelectorAll(itemSelector)[selectedIndex] as HTMLElement; + if (targetItem) { + menu.scrollTop = + targetItem.offsetTop - + Math.floor((menu.clientHeight / targetItem.clientHeight - 1) / 2) * + targetItem.clientHeight; + } + } + } + + renderMenu( + items: ReactNodeArray, + ref: LegacyRef> | undefined, + props: MenuProps + ) { + function isItem(node: ReactNode): node is ComponentElement { + // FIXME 这里的判断很容易报错,node.type 可以是 string 或者函数组件 + return ( + React.isValidElement(node) && + (node.type as typeof CascaderMenuItem).menuChildType === 'item' + ); + } + return ( + + {items.map(node => { + if (isItem(node)) { + return React.cloneElement(node, { + menu: this, + }); + } + + return node; + })} + + ); + } + + saveMenuRef = (ref: HTMLDivElement) => { + this.menuEl = ref; + }; + + saveVirtualRef = (ref: InstanceType) => { + this.virtualEl = ref; + }; + + render() { + const { prefix, useVirtual, className, style, children, ...others } = this.props; + const menuProps = { + labelToggleChecked: false, + className: `${prefix}cascader-menu`, + ...others, + }; + return ( +
    + {useVirtual ? ( + this.renderMenu(items, ref, menuProps)} + > + {children} + + ) : ( + this.renderMenu(children as ReactNodeArray, undefined, menuProps) + )} +
    + ); + } +} diff --git a/components/cascader/mobile/index.jsx b/components/cascader/mobile/index.jsx deleted file mode 100644 index abacad79cf..0000000000 --- a/components/cascader/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Cascader as MeetCascader } from '@alifd/meet-react'; -import NextCascader from '../index'; - -const Cascader = MeetCascader ? MeetCascader : NextCascader; - -export default Cascader; diff --git a/components/cascader/mobile/index.tsx b/components/cascader/mobile/index.tsx new file mode 100644 index 0000000000..22cabea4e0 --- /dev/null +++ b/components/cascader/mobile/index.tsx @@ -0,0 +1,7 @@ +// @ts-expect-error meet-react not export Cascader +import { Cascader as MeetCascader } from '@alifd/meet-react'; +import NextCascader from '../index'; + +const Cascader = MeetCascader ? MeetCascader : NextCascader; + +export default Cascader; diff --git a/components/cascader/style.js b/components/cascader/style.ts similarity index 100% rename from components/cascader/style.js rename to components/cascader/style.ts diff --git a/components/cascader/types.ts b/components/cascader/types.ts new file mode 100644 index 0000000000..72f7b1f590 --- /dev/null +++ b/components/cascader/types.ts @@ -0,0 +1,307 @@ +import type React from 'react'; +import type { CommonProps } from '../util'; +import type { CheckboxItemProps, MenuProps, ItemProps as MenuItemProps } from '../menu'; + +interface HTMLAttributesWeak extends React.HTMLAttributes {} + +/** + * @api CascaderDataItem + */ +export type CascaderDataItem = { + value: string; + label?: React.ReactNode; + disabled?: boolean; + checkboxDisabled?: boolean; + children?: Array; + title?: string; + [propName: string]: unknown; +}; + +/** + * @api CascaderDataItemWithPosInfo + */ +export type CascaderDataItemWithPosInfo = CascaderDataItem & { + /** + * 位置信息 + */ + pos: string; + _source?: CascaderDataItem; +}; + +export type V2n = Record; +export type P2n = V2n; + +export type NormalizeValueReturns = T extends undefined | null + ? [] + : T extends unknown[] + ? T + : [T]; + +/** + * @api Extra + */ +export type Extra = { + /** + * 单选时选中的数据的路径 + */ + selectedPath?: Array; + /** + * 多选时当前的操作是选中还是取消选中 + */ + checked?: boolean; + /** + * 多选时当前操作的数据 + */ + currentData?: CascaderDataItem; + /** + * 多选时所有被选中的数据 + */ + checkedData?: Array; + /** + * 多选时半选的数据 + */ + indeterminateData?: Array; +}; + +export interface CascaderState { + value: string[]; + isExpandedValueSetByAction: boolean; + _v2n: V2n; + _p2n: P2n; + expandedValue: string[]; + focusedValue?: string; +} + +export interface ItemProps + extends CommonProps, + Omit, + MenuItemProps { + onExpand?: (focusedFirstChild: boolean) => void | undefined | Promise; + disabled?: boolean; + canExpand?: boolean; + className?: string; + onFold?: () => void; + selected?: boolean; + expanded?: boolean; + expandTriggerType?: 'click' | 'hover'; + checkable?: boolean; + onCheck?: CheckboxItemProps['onChange']; +} + +export interface ItemState { + loading: boolean; +} + +export interface CascaderMenuProps extends CommonProps, MenuProps { + useVirtual?: boolean; + children: Array; +} + +/** + * @api Cascader + * @remarks + * Cascader 支持除 onSelect、value、onChange、defaultValue、focusedKey、onItemFocus、focusable、isSelectIconRight、onBlur 等和 Cascader 同名的属性透传给 Menu + * - + * Cascader supports passing through properties with the same names to Menu, such as onSelect, value, onChange, defaultValue, focusedKey, onItemFocus, focusable, isSelectIconRight, onBlur, etc. + */ +export interface CascaderProps + extends Omit, + CommonProps, + Omit< + MenuProps, + | 'value' + | 'onChange' + | 'onSelect' + | 'defaultValue' + | 'focusedKey' + | 'onItemFocus' + | 'focusable' + | 'isSelectIconRight' + | 'onBlur' + > { + /** + * 数据源 + * @en data source + * @defaultValue [] + */ + dataSource?: Array; + + /** + * (非受控)默认值 + * @en default value + */ + defaultValue?: string | Array; + + /** + * (受控)当前值 + * @en current value + */ + value?: string | Array; + + /** + * 选中值改变时触发的回调函数 + * @en callback when value changed + * @param value - 选中的值,单选时返回单个值,多选时返回数组 - selected value, single value when single select, array when multiple select + * @param data - 选中的数据,包括 value 和 label,单选时返回单个值,多选时返回数组,父子节点选中关联时,同时选中,只返回父节点 - selected data, including value and label, single value when single select, array when multiple select + * @param extra - 额外参数 - extra parameters + */ + onChange?: ( + value: string | Array, + data: CascaderDataItem | Array, + extra: Extra + ) => void; + + /** + * 选中时触发的回调函数 + * @en callback when selected + * @remarks 无论值是否发生变化 - no matter value changed + * @param v - 选中的值 - selected value + * @param data - 选中的数据,包括 value 和 label - selected data, including value and label + * @param extra - 额外参数 - extra parameters + */ + onSelect?: (v: string, data: CascaderDataItemWithPosInfo, extra: Extra) => void; + + /** + * (非受控)默认展开值 + * @en default expanded value + * @remarks 如果不设置,组件内部会根据 defaultValue/value 进行自动设置 - if not set, component will set automatically based on defaultValue/value + */ + defaultExpandedValue?: Array; + + /** + * (受控)当前展开值 + * @en current expanded value + */ + expandedValue?: Array; + + /** + * 展开触发的方式 + * @en expand trigger type + * @defaultValue 'click' + */ + expandTriggerType?: 'click' | 'hover'; + + /** + * @deprecated use expandTriggerType instead + * @skip + */ + expandTrigger?: 'click' | 'hover'; + + /** + * 展开时触发的回调函数 + * @en callback when expanded + * @param expandedValue - 各列展开值的数组 - expanded value + */ + onExpand?: (expandedValue: Array) => void; + + /** + * 是否开启虚拟滚动,开启后建议设置 listStyle 固定列宽 + * @en use virtual scroll, recommend set listStyle fixed width when enable + * @defaultValue false + */ + useVirtual?: boolean; + + /** + * 是否多选 + * @en multiple + * @defaultValue false + */ + multiple?: boolean; + + /** + * 单选时是否只能选中叶子节点 + * @en can only select leaf when single select + * @defaultValue false + */ + canOnlySelectLeaf?: boolean; + + /** + * 多选时是否只能选中叶子节点 + * @en can only check leaf when multiple select + * @defaultValue false + */ + canOnlyCheckLeaf?: boolean; + + /** + * 父子节点是否选中不关联 + * @en check parent and child not associated + * @defaultValue false + */ + checkStrictly?: boolean; + + /** + * 每列列表样式对象 + * @en list style + */ + listStyle?: React.CSSProperties; + + /** + * 每列列表类名 + * @en list class + */ + listClassName?: string; + + /** + * 每列列表项渲染函数 + * @en list item render + * @param data - 数据 - data + * @param props - 列表项属性 - list item props + * @returns 列表项内容 - list item content + * @defaultValue (item: CascaderDataItem) =\> item.label + */ + itemRender?: (data: CascaderDataItem, props: ItemProps) => React.ReactNode; + + /** + * 异步加载数据函数,source 是原始对象 + * @en async load data function + * @param data - 当前点击异步加载的数据 - current click data + * @param source - 当前点击数据,source 是原始对象 - current click data, source is original object + */ + loadData?: (data: CascaderDataItem, source: CascaderDataItem) => Promise; + + /** + * 是否是不可变数据 + * @en immutable + * @defaultValue false + * @version 1.23.0 + */ + immutable?: boolean; + /** + * 搜索值 + * @en search value + * @skip + */ + searchValue?: string; + + /** + * 过滤后的路径 + * @en filtered paths + * @skip + */ + filteredPaths?: CascaderDataItemWithPosInfo[][]; + + /** + * 结果渲染函数 + * @en result render + * @param searchValue - 搜索值 - search value + * @param path - 路径 - path + * @returns 结果内容 - result content + * @skip + */ + resultRender?: (searchValue: string, path: CascaderDataItemWithPosInfo[]) => React.ReactNode; + + /** + * 过滤后的列表样式对象 + * @en filter list style + * @skip + */ + filteredListStyle?: React.CSSProperties; + + /** + * 是否可聚焦 + * @en focusable + * @defaultValue false + * @skip + */ + focusable?: boolean; +} diff --git a/components/cascader/utils.js b/components/cascader/utils.js deleted file mode 100644 index bd49219104..0000000000 --- a/components/cascader/utils.js +++ /dev/null @@ -1,187 +0,0 @@ -/* eslint-disable valid-jsdoc */ -export function normalizeToArray(values) { - if (values !== undefined && values !== null) { - if (Array.isArray(values)) { - return [...values]; - } - - return [values]; - } - - return []; -} - -/** - * 判断子节点是否是选中状态,如果 checkable={false} 则向下递归, - * @param {Node} child - * @param {Array} checkedValues - */ -export function isNodeChecked(node, checkedValues) { - if (node.disabled || node.checkboxDisabled) return true; - /* istanbul ignore next */ - if (node.checkable === false) { - return ( - !node.children || node.children.length === 0 || node.children.every(c => isNodeChecked(c, checkedValues)) - ); - } - return checkedValues.indexOf(node.value) > -1; -} - -/** - * 遍历所有可用的子节点 - * @param {Node} - * @param {Function} callback - */ -export function forEachEnableNode(node, callback = () => {}) { - if (node.disabled || node.checkboxDisabled) return; - // eslint-disable-next-line callback-return - callback(node); - if (node.children && node.children.length > 0) { - node.children.forEach(child => forEachEnableNode(child, callback)); - } -} -/** - * 判断节点是否禁用checked - * @param {Node} node - * @returns {Boolean} - */ -export function isNodeDisabledChecked(node) { - if (node.disabled || node.checkboxDisabled) return true; - /* istanbul ignore next */ - if (node.checkable === false) { - return !node.children || node.children.length === 0 || node.children.every(isNodeDisabledChecked); - } - - return false; -} - -/** - * 递归获取一个 checkable = {true} 的父节点,当 checkable={false} 时继续往上查找 - * @param {Node} node - * @param {Map} _p2n - * @return {Node} - */ -export function getCheckableParentNode(node, _p2n) { - let parentPos = node.pos.split(['-']); - if (parentPos.length === 2) return node; - parentPos.splice(parentPos.length - 1, 1); - parentPos = parentPos.join('-'); - const parentNode = _p2n[parentPos]; - if (parentNode.disabled || parentNode.checkboxDisabled) return false; - /* istanbul ignore next */ - if (parentNode.checkable === false) { - return getCheckableParentNode(parentNode, _p2n); - } - - return parentNode; -} -/** - * 过滤子节点 - * @param {Array} values - * @param {Object} _v2n - */ -export function filterChildValue(values, _v2n, _p2n) { - const newValues = []; - values.forEach(value => { - const node = getCheckableParentNode(_v2n[value], _p2n); - if (!node || node.checkable === false || node === _v2n[value] || values.indexOf(node.value) === -1) { - newValues.push(value); - } - }); - return newValues; -} - -export function filterParentValue(values, _v2n) { - const newValues = []; - - for (let i = 0; i < values.length; i++) { - const node = _v2n[values[i]]; - if (!node.children || node.children.length === 0 || node.children.every(isNodeDisabledChecked)) { - newValues.push(values[i]); - } - } - - return newValues; -} - -export function isDescendantOrSelf(currentPos, targetPos) { - if (!currentPos || !targetPos) { - return false; - } - - const currentNums = currentPos.split('-'); - const targetNums = targetPos.split('-'); - - return ( - currentNums.length <= targetNums.length && - currentNums.every((num, index) => { - return num === targetNums[index]; - }) - ); -} - -export function isSiblingOrSelf(currentPos, targetPos) { - const currentNums = currentPos.split('-').slice(0, -1); - const targetNums = targetPos.split('-').slice(0, -1); - - return ( - currentNums.length === targetNums.length && - currentNums.every((num, index) => { - return num === targetNums[index]; - }) - ); -} - -// eslint-disable-next-line max-statements -export function getAllCheckedValues(checkedValues, _v2n, _p2n) { - checkedValues = normalizeToArray(checkedValues); - const filteredValues = checkedValues.filter(value => !!_v2n[value]); - const flatValues = [ - ...filterChildValue(filteredValues, _v2n, _p2n), - ...filteredValues.filter(value => _v2n[value].disabled || _v2n[value].checkboxDisabled), - ]; - const removeValue = child => { - if (child.disabled || child.checkboxDisabled) return; - if (child.checkable === false && child.children && child.children.length > 0) { - return child.children.forEach(removeValue); - } - flatValues.splice(flatValues.indexOf(child.value), 1); - }; - - const addParentValue = (i, parent) => flatValues.splice(i, 0, parent.value); - - const values = [...flatValues]; - for (let i = 0; i < values.length; i++) { - const pos = _v2n[values[i]].pos; - const nums = pos.split('-'); - if (nums.length === 2) { - break; - } - for (let j = nums.length - 2; j > 0; j--) { - const parentPos = nums.slice(0, j + 1).join('-'); - const parent = _p2n[parentPos]; - if (parent.checkable === false || parent.disabled || parent.checkboxDisabled) continue; - const parentChecked = parent.children.every(child => isNodeChecked(child, flatValues)); - if (parentChecked) { - parent.children.forEach(removeValue); - addParentValue(i, parent); - } else { - break; - } - } - } - - const newValues = []; - flatValues.forEach(value => { - if (_v2n[value].disabled || _v2n[value].checkboxDisabled) { - newValues.push(value); - return; - } - forEachEnableNode(_v2n[value], node => { - if (node.checkable === false) return; - newValues.push(node.value); - }); - }); - - return newValues; -} diff --git a/components/cascader/utils.ts b/components/cascader/utils.ts new file mode 100644 index 0000000000..99ffccf5db --- /dev/null +++ b/components/cascader/utils.ts @@ -0,0 +1,228 @@ +import type { + CascaderDataItem, + CascaderDataItemWithPosInfo, + NormalizeValueReturns, + P2n, + V2n, +} from './types'; + +/** + * 将 values 正规化为数组形式 + * @param values - 要被正规化的值 + * @returns 正规化为数组形式的值 + */ +export function normalizeToArray(values: T): NormalizeValueReturns { + if (values !== undefined && values !== null) { + if (Array.isArray(values)) { + return [...values] as NormalizeValueReturns; + } + + return [values] as NormalizeValueReturns; + } + return [] as NormalizeValueReturns; +} + +/** + * 判断子节点是否是选中状态,如果 checkable=false 则向下递归, + * @param child - 子节点 + * @param checkedValues - 选中的值 + */ +export function isNodeChecked(node: CascaderDataItem, checkedValues: string[]): boolean { + if (node.disabled || node.checkboxDisabled) return true; + if (node.checkable === false) { + return ( + !node.children || + node.children.length === 0 || + node.children.every(c => isNodeChecked(c, checkedValues)) + ); + } + return checkedValues.indexOf(node.value) > -1; +} + +/** + * 遍历所有可用的子节点 + * @param node - 子节点 + * @param callback - 遍历的回调 + */ +export function forEachEnableNode( + node: CascaderDataItem, + callback: (node: CascaderDataItem) => void = () => {} +) { + if (node.disabled || node.checkboxDisabled) return; + callback(node); + if (node.children && node.children.length > 0) { + node.children.forEach(child => forEachEnableNode(child, callback)); + } +} +/** + * 判断节点是否禁用 checked + * @param node - 节点 + */ +export function isNodeDisabledChecked(node: CascaderDataItem): boolean { + if (node.disabled || node.checkboxDisabled) return true; + if (node.checkable === false) { + return ( + !node.children || + node.children.length === 0 || + node.children.every(isNodeDisabledChecked) + ); + } + + return false; +} + +/** + * 递归获取一个 checkable=true 的父节点,当 checkable=false 时继续往上查找 + * @param node - 子节点 + * @param _p2n - 位置信息 + * @returns checkable=true 的父节点 + */ +export function getCheckableParentNode(node: CascaderDataItemWithPosInfo, _p2n: P2n) { + let parentPos: string | string[] = node.pos.split('-'); + if (parentPos.length === 2) return node; + parentPos.splice(parentPos.length - 1, 1); + parentPos = parentPos.join('-'); + const parentNode = _p2n[parentPos]; + if (parentNode.disabled || parentNode.checkboxDisabled) return false; + if (parentNode.checkable === false) { + return getCheckableParentNode(parentNode, _p2n); + } + + return parentNode; +} +/** + * 过滤子节点的值 + * @param values - 子节点的值 + * @param _v2n - 节点信息 + * @param _p2n - 位置信息 + */ +export function filterChildValue(values: string[], _v2n: V2n, _p2n: P2n) { + const newValues: string[] = []; + values.forEach(value => { + const node = getCheckableParentNode(_v2n[value], _p2n); + if ( + !node || + node.checkable === false || + node === _v2n[value] || + values.indexOf(node.value) === -1 + ) { + newValues.push(value); + } + }); + return newValues; +} + +export function filterParentValue(values: string[], _v2n: V2n) { + const newValues = []; + + for (let i = 0; i < values.length; i++) { + const node = _v2n[values[i]]; + if ( + !node.children || + node.children.length === 0 || + node.children.every(isNodeDisabledChecked) + ) { + newValues.push(values[i]); + } + } + + return newValues; +} +/** + * 判断当前节点是否是目标节点的子孙节点 + * @param currentPos - 当前节点的位置 + * @param targetPos - 目标节点的位置 + */ +export function isDescendantOrSelf(currentPos: string, targetPos: string) { + if (!currentPos || !targetPos) { + return false; + } + + const currentNums = currentPos.split('-'); + const targetNums = targetPos.split('-'); + + return ( + currentNums.length <= targetNums.length && + currentNums.every((num, index) => { + return num === targetNums[index]; + }) + ); +} + +/** + * 判断当前节点是否是目标节点的兄弟节点 + * @param currentPos - 当前节点的位置 + * @param targetPos - 目标节点的位置 + */ +export function isSiblingOrSelf(currentPos: string, targetPos: string) { + const currentNums = currentPos.split('-').slice(0, -1); + const targetNums = targetPos.split('-').slice(0, -1); + + return ( + currentNums.length === targetNums.length && + currentNums.every((num, index) => { + return num === targetNums[index]; + }) + ); +} + +/** + * 获取所有选中的值 + * @param checkedValues - 候选值 + * @param _v2n - 节点信息 + * @param _p2n - 位置信息 + * @returns 所有选中的值 + */ +export function getAllCheckedValues(checkedValues: string[], _v2n: V2n, _p2n: P2n) { + checkedValues = normalizeToArray(checkedValues); + const filteredValues = checkedValues.filter(value => !!_v2n[value]); + const flatValues = [ + ...filterChildValue(filteredValues, _v2n, _p2n), + ...filteredValues.filter(value => _v2n[value].disabled || _v2n[value].checkboxDisabled), + ]; + const removeValue = (child: V2n[keyof V2n]) => { + if (child.disabled || child.checkboxDisabled) return; + if (child.checkable === false && child.children && child.children.length > 0) { + return child.children.forEach(removeValue); + } + flatValues.splice(flatValues.indexOf(child.value), 1); + }; + + const addParentValue = (i: number, parent: V2n[keyof V2n]) => + flatValues.splice(i, 0, parent.value); + + const values = [...flatValues]; + for (let i = 0; i < values.length; i++) { + const pos = _v2n[values[i]].pos; + const nums = pos.split('-'); + if (nums.length === 2) { + break; + } + for (let j = nums.length - 2; j > 0; j--) { + const parentPos = nums.slice(0, j + 1).join('-'); + const parent = _p2n[parentPos]; + if (parent.checkable === false || parent.disabled || parent.checkboxDisabled) continue; + const parentChecked = parent.children!.every(child => isNodeChecked(child, flatValues)); + if (parentChecked) { + parent.children!.forEach(removeValue); + addParentValue(i, parent); + } else { + break; + } + } + } + + const newValues: string[] = []; + flatValues.forEach(value => { + if (_v2n[value].disabled || _v2n[value].checkboxDisabled) { + newValues.push(value); + return; + } + forEachEnableNode(_v2n[value], node => { + if (node.checkable === false) return; + newValues.push(node.value); + }); + }); + + return newValues; +} diff --git a/components/checkbox/__docs__/demo/all-check/index.tsx b/components/checkbox/__docs__/demo/all-check/index.tsx index 1b6bcd378f..8c78b5a67a 100644 --- a/components/checkbox/__docs__/demo/all-check/index.tsx +++ b/components/checkbox/__docs__/demo/all-check/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox, Divider } from '@alifd/next'; +import type { CheckboxProps, GroupProps } from '@alifd/next/types/checkbox'; const CheckboxGroup = Checkbox.Group; @@ -12,13 +13,13 @@ const App = () => { const [indeterminate, setIndeterminate] = React.useState(true); const [checkAll, setCheckAll] = React.useState(false); - const onChange = list => { + const onChange: GroupProps['onChange'] = (list: string[]) => { setCheckedList(list); setIndeterminate(!!list.length && list.length < plainOptions.length); setCheckAll(list.length === plainOptions.length); }; - const onCheckAllChange = (checked, e) => { + const onCheckAllChange: CheckboxProps['onChange'] = (checked, e) => { setCheckedList(e.target.checked ? plainOptions : []); setIndeterminate(false); setCheckAll(e.target.checked); diff --git a/components/checkbox/__docs__/demo/control/index.tsx b/components/checkbox/__docs__/demo/control/index.tsx index 986b656c67..14de331877 100644 --- a/components/checkbox/__docs__/demo/control/index.tsx +++ b/components/checkbox/__docs__/demo/control/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox } from '@alifd/next'; +import { type GroupProps } from '@alifd/next/types/checkbox'; const list = [ { @@ -18,26 +19,16 @@ const list = [ ]; class ControlApp extends React.Component { - constructor(props) { - super(props); - - this.state = { - value: 'orange', - }; - - this.onChange = this.onChange.bind(this); - } + state = { + value: 'orange', + }; - onChange(value) { + onChange: GroupProps['onChange'] = value => { this.setState({ value: value, }); console.log('onChange', value); - } - - onClick(e) { - console.log('onClick', e); - } + }; render() { return ( diff --git a/components/checkbox/__docs__/demo/dataSource/index.tsx b/components/checkbox/__docs__/demo/dataSource/index.tsx index 3a0192677c..2d056b26b8 100644 --- a/components/checkbox/__docs__/demo/dataSource/index.tsx +++ b/components/checkbox/__docs__/demo/dataSource/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox } from '@alifd/next'; +import { type GroupProps } from '@alifd/next/types/checkbox'; const list = [ { @@ -20,15 +21,11 @@ const list = [ ]; class App extends React.Component { - constructor(props) { - super(props); - - this.state = { - value: 'apple', - }; - } + state = { + value: 'apple', + }; - onChange = value => { + onChange: GroupProps['onChange'] = value => { this.setState({ value: value, }); @@ -36,12 +33,7 @@ class App extends React.Component { render() { return ( - + ); } } diff --git a/components/checkbox/__docs__/demo/group/index.tsx b/components/checkbox/__docs__/demo/group/index.tsx index af18e679a4..cca1e1849a 100644 --- a/components/checkbox/__docs__/demo/group/index.tsx +++ b/components/checkbox/__docs__/demo/group/index.tsx @@ -1,23 +1,18 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox } from '@alifd/next'; +import { type GroupProps } from '@alifd/next/types/checkbox'; class App extends React.Component { - constructor(props) { - super(props); + state = { + value: 'orange', + }; - this.state = { - value: 'orange', - }; - - this.onChange = this.onChange.bind(this); - } - - onChange(value) { + onChange: GroupProps['onChange'] = value => { this.setState({ value: value, }); - } + }; render() { return ( diff --git a/components/checkbox/__docs__/demo/indeterminate/index.tsx b/components/checkbox/__docs__/demo/indeterminate/index.tsx index 8af9451534..c7069f50f0 100644 --- a/components/checkbox/__docs__/demo/indeterminate/index.tsx +++ b/components/checkbox/__docs__/demo/indeterminate/index.tsx @@ -3,15 +3,11 @@ import ReactDOM from 'react-dom'; import { Checkbox, Button } from '@alifd/next'; class IndeterminateApp extends React.Component { - constructor(props) { - super(props); - - this.state = { - checked: false, - indeterminate: true, - disabled: false, - }; - } + state = { + checked: false, + indeterminate: true, + disabled: false, + }; toggle = () => { if (this.state.indeterminate) { diff --git a/components/checkbox/__docs__/demo/isPreview/index.tsx b/components/checkbox/__docs__/demo/isPreview/index.tsx index 54372ad59c..0259f4a764 100644 --- a/components/checkbox/__docs__/demo/isPreview/index.tsx +++ b/components/checkbox/__docs__/demo/isPreview/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox, Switch } from '@alifd/next'; +import { type CheckboxProps, type GroupProps } from '@alifd/next/types/checkbox'; class App extends React.Component { state = { @@ -20,10 +21,10 @@ class App extends React.Component { }); }; - renderChecked = (checked, props) => + renderChecked: CheckboxProps['renderPreview'] = (checked, props) => checked ? {props.children} : null; - renderPreview = (previewed, props) => + renderPreview: GroupProps['renderPreview'] = previewed => previewed.length ? previewed.map((Item, index) => ( diff --git a/components/checkbox/__docs__/demo/uncontrol/index.tsx b/components/checkbox/__docs__/demo/uncontrol/index.tsx index f4cbdc7677..e247363093 100644 --- a/components/checkbox/__docs__/demo/uncontrol/index.tsx +++ b/components/checkbox/__docs__/demo/uncontrol/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Checkbox } from '@alifd/next'; +import { type GroupProps } from '@alifd/next/types/checkbox'; const { Group: CheckboxGroup } = Checkbox; const list = [ @@ -21,15 +22,9 @@ const list = [ ]; class UnControlApp extends React.Component { - constructor(props) { - super(props); - - this.onChange = this.onChange.bind(this); - } - - onChange(selectedItems) { + onChange: GroupProps['onChange'] = selectedItems => { console.log('onChange callback', selectedItems); - } + }; render() { return ( diff --git a/components/checkbox/__docs__/index.en-us.md b/components/checkbox/__docs__/index.en-us.md index 7defeb1fd2..d76ea63596 100644 --- a/components/checkbox/__docs__/index.en-us.md +++ b/components/checkbox/__docs__/index.en-us.md @@ -13,41 +13,67 @@ Checkbox ### When to Use Checkbox is used to verify which options you want selected from a group. If you have only a single option, do not use the checkbox, use the on/off switch. + ## API ### Checkbox -| Param | Description | Type | Default Value | -| ------------------------ |---------------------------- | ------------ | ------------- | -| id | checkbox id, mounted on input | String | - | -| checked | Set the status to be checked | Boolean | - | -| defaultChecked | Set the default status to be checked | Boolean | false | -| disabled | Set the status to be disabled | Boolean | - | -| label | Set the label by property | String | - | -| indeterminate | The intermediate state of the Checkbox will only affect the style of the Checkbox and does not affect its checked property. | Boolean | - | -| defaultIndeterminate | Set the default status to intermediate, it will only affect the style of the Checkbox and does not affect its checked property. | Boolean | false | -| onChange | Callback function triggered when the state changes

    **signatures**:
    Function(checked: Boolean, e: Event) => void
    **params**:
    _checked_: {Boolean} The checked value of the underlying checkbox input
    _e_: {Event} Dom event object | Function | func.noop | -| onMouseEnter | Callback function triggered when the mouse pointer enters the element.

    **signatures**:
    Function(e: Event) => void
    **params**:
    _e_: {Event} Dom event object | Function | func.noop | -| onMouseLeave | Callback function triggered when the mouse pointer leaves the element.

    **signatures**:
    Function(e: Event) => void
    **params**:
    _e_: {Event} Dom event object | Function | func.noop | -|value | The value of the Checkbox | String/Number/Boolean | - | +| Param | Description | Type | Default Value | Required | Supported Version | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------------- | -------- | ----------------- | +| className | ClassName | string | - | | - | +| id | Checkbox id, mounted on the input | string | - | | - | +| style | Custom inline style | React.CSSProperties | - | | - | +| checked | Checked status | boolean | - | | - | +| value | Checkbox value | ValueItem | - | | - | +| name | Name | string | - | | - | +| defaultChecked | Default checked status | boolean | false | | - | +| disabled | Disabled | boolean | - | | - | +| label | Label | React.ReactNode | - | | - | +| indeterminate | Checkbox middle status, only affects the style of Checkbox, and does not affect its checked property | boolean | - | | - | +| defaultIndeterminate | Checkbox default middle status, only affects the style of Checkbox, and does not affect its checked property | boolean | false | | - | +| onChange | Status change event | (checked: boolean, e: React.ChangeEvent\) => void | - | | - | +| onMouseEnter | Mouse enter event | (e: React.MouseEvent\) => void | - | | - | +| onMouseLeave | Mouse leave event | (e: React.MouseEvent\) => void | - | | - | +| isPreview | Is preview | boolean | false | | 1.19 | +| renderPreview | Custom rendering content

    **signature**:
    **params**:
    _checked_: Is checked
    _props_: All props
    **return**:
    Custom rendering content | (checked: boolean, props: CheckboxProps) => React.ReactNode | - | | 1.19 | ### Checkbox.Group -| params | desc | type | default | -| ---------------- | --------------------------------------------------- | -------- | ------------- | -| disabled | Set the status of all checkbox in group to be checked | Boolean | - | -| dataSource | Optional list, data item can be String or Object, for example `['apple', 'pear', 'orange']` or `[{value: 'apple', label: 'Apple',}, {value: 'pear', label: 'Pear'}, {value: 'orange', label: 'Orange'}]` | Array<any> | \[] | -| value | The values of selected optional list | Array/String/Number/Boolean | - | -| defaultValue | The values of default selected optional list | Array/String/Number/Boolean | - | -| children | To set nested checkbox by children components | Array<ReactElement> | - | -| onChange | Callback function triggered when the selected value changes

    **signatures**:
    Function(value: Array, e: Event) => void
    **params**:
    _value_: {Array} values of selected optional list
    _e_: {Event} Dom event object | Function | () => { } | -| direction | The direction of item's aligning
    - hoz: horizontal (default)
    - ver: vertical

    **Allowed values**:
    'hoz', 'ver' | Enum | 'hoz' | +| Param | Description | Type | Default Value | Required | Supported Version | +| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- | -------- | ----------------- | +| className | Custom className | string | - | | - | +| style | Custom inline style | React.CSSProperties | - | | - | +| disabled | Entirely disabled | boolean | - | | - | +| dataSource | Option list | Array\ \| Array\ | - | | - | +| value | Selected value list | ValueItem[] \| ValueItem | - | | - | +| defaultValue | Default selected value list | ValueItem[] \| ValueItem | - | | - | +| name | Name | string | - | | - | +| children | Set internal checkbox through child elements | React.ReactNode | - | | - | +| onChange | Selected value change event | (value: ValueItem[], e: React.ChangeEvent\) => void | - | | - | +| direction | Arrangement of subitems | 'hoz' \| 'ver' | - | | - | +| itemDirection | [Deprecated] Arrangement of subitems | 'hoz' \| 'ver' | - | | - | +| isPreview | Is preview | boolean | - | | 1.19 | +| renderPreview | Custom rendering content

    **signature**:
    **params**:
    _previewed_: Previewed value [\{label: '', value:''\},...]
    _props_: All props
    **return**:
    Custom rendering content | (
    previewed: {
    label: string \| React.ReactNode;
    value: string \| React.ReactNode;
    }[],
    props: object
    ) => React.ReactNode | - | | 1.19 | + +### ValueItem +```typescript +export type ValueItem = string | number | boolean; +``` +### CheckboxData +```typescript +export type CheckboxData = { + value: ValueItem; + label?: React.ReactNode; + disabled?: boolean; + [propName: string]: unknown; +}; +``` ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| SPACE | Select or cancel the current item | +| KeyBoard | Descripiton | +| :------- | :-------------------------------- | +| SPACE | Select or cancel the current item | diff --git a/components/checkbox/__docs__/index.md b/components/checkbox/__docs__/index.md index a8b1db55ab..8b4ff4d325 100644 --- a/components/checkbox/__docs__/index.md +++ b/components/checkbox/__docs__/index.md @@ -18,39 +18,62 @@ ### Checkbox -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------- | --------- | ---- | -| id | checkbox id, 挂载在input上 | String | - | | -| checked | 选中状态 | Boolean | - | | -| defaultChecked | 默认选中状态 | Boolean | false | | -| disabled | 禁用 | Boolean | - | | -| label | 通过属性配置label, | ReactNode | - | | -| indeterminate | Checkbox 的中间状态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 | Boolean | - | | -| defaultIndeterminate | Checkbox 的默认中间态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 | Boolean | false | | -| onChange | 状态变化时触发的事件

    **签名**:
    Function(checked: Boolean, e: Event) => void
    **参数**:
    _checked_: {Boolean} 是否选中
    _e_: {Event} Dom 事件对象 | Function | func.noop | | -| onMouseEnter | 鼠标进入enter事件

    **签名**:
    Function(e: Event) => void
    **参数**:
    _e_: {Event} Dom 事件对象 | Function | func.noop | | -| onMouseLeave | 鼠标离开Leave事件

    **签名**:
    Function(e: Event) => void
    **参数**:
    _e_: {Event} Dom 事件对象 | Function | func.noop | | -| value | checkbox 的value | String/Number/Boolean | - | | -| name | name | String | - | | -| isPreview | 是否为预览态 | Boolean | false | 1.19 | -| renderPreview | 预览态模式下渲染的内容

    **签名**:
    Function(checked: Boolean, props: Object) => reactNode
    **参数**:
    _checked_: {Boolean} 是否选中
    _props_: {Object} 所有传入的参数
    **返回值**:
    {reactNode} Element 渲染内容
    | Function | - | 1.19 | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------- | ------ | -------- | -------- | +| className | 自定义类名 | string | - | | - | +| id | checkbox id, 挂载在 input 上 | string | - | | - | +| style | 自定义内联样式 | React.CSSProperties | - | | - | +| checked | 选中状态 | boolean | - | | - | +| value | checkbox 的 value | ValueItem | - | | - | +| name | name | string | - | | - | +| defaultChecked | 默认选中状态 | boolean | false | | - | +| disabled | 禁用 | boolean | - | | - | +| label | 通过属性配置 label, | React.ReactNode | - | | - | +| indeterminate | Checkbox 的中间状态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 | boolean | - | | - | +| defaultIndeterminate | Checkbox 的默认中间态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 | boolean | false | | - | +| onChange | 状态变化时触发的事件 | (checked: boolean, e: React.ChangeEvent\) => void | - | | - | +| onMouseEnter | 鼠标进入 enter 事件 | (e: React.MouseEvent\) => void | - | | - | +| onMouseLeave | 鼠标离开 Leave 事件 | (e: React.MouseEvent\) => void | - | | - | +| isPreview | 是否为预览态 | boolean | false | | 1.19 | +| renderPreview | 预览态模式下渲染的内容

    **签名**:
    **参数**:
    _checked_: 是否选中
    _props_: 所有传入的参数
    **返回值**:
    定制渲染内容 | (checked: boolean, props: CheckboxProps) => React.ReactNode | - | | 1.19 | ### Checkbox.Group -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------- | -------- | ---- | -| disabled | 整体禁用 | Boolean | - | | -| dataSource | 可选项列表, 数据项可为 String 或者 Object, 如 `['apple', 'pear', 'orange']` 或者 `[{value: 'apple', label: '苹果',}, {value: 'pear', label: '梨'}, {value: 'orange', label: '橙子'}]` | Array<String>/Array<Object> | \[] | | -| value | 被选中的值列表 | Array/String/Number/Boolean | - | | -| defaultValue | 默认被选中的值列表 | Array/String/Number/Boolean | - | | -| children | 通过子元素方式设置内部 checkbox | Array<ReactElement> | - | | -| onChange | 选中值改变时的事件

    **签名**:
    Function(value: Array, e: Event) => void
    **参数**:
    _value_: {Array} 选中项列表
    _e_: {Event} Dom 事件对象 | Function | () => {} | | -| direction | 子项目的排列方式
    - hoz: 水平排列 (default)
    - ver: 垂直排列

    **可选值**:
    'hoz', 'ver' | Enum | 'hoz' | | -| isPreview | 是否为预览态 | Boolean | false | 1.19 | -| renderPreview | 预览态模式下渲染的内容

    **签名**:
    Function(previewed: Array, props: Object) => reactNode
    **参数**:
    _previewed_: {Array} 预览值 [{label: '', value:''},...]
    _props_: {Object} 所有传入的参数
    **返回值**:
    {reactNode} Element 渲染内容
    | Function | - | 1.19 | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ------ | -------- | -------- | +| className | 自定义类名 | string | - | | - | +| style | 自定义内联样式 | React.CSSProperties | - | | - | +| disabled | 整体禁用 | boolean | - | | - | +| dataSource | 可选项列表 | Array\ \| Array\ | - | | - | +| value | 被选中的值列表 | ValueItem[] \| ValueItem | - | | - | +| defaultValue | 默认被选中的值列表 | ValueItem[] \| ValueItem | - | | - | +| name | name | string | - | | - | +| children | 通过子元素方式设置内部 checkbox | React.ReactNode | - | | - | +| onChange | 选中值改变时的事件 | (value: ValueItem[], e: React.ChangeEvent\) => void | - | | - | +| direction | 子项目的排列方式 | 'hoz' \| 'ver' | - | | - | +| itemDirection | [废弃] 子项目的排列方式 | 'hoz' \| 'ver' | - | | - | +| isPreview | 是否为预览态 | boolean | - | | 1.19 | +| renderPreview | 预览态模式下渲染的内容

    **签名**:
    **参数**:
    _previewed_: 预览值 [\{label: '', value:''\},...]
    _props_: 所有传入的参数
    **返回值**:
    定制渲染内容 | (
    previewed: {
    label: string \| React.ReactNode;
    value: string \| React.ReactNode;
    }[],
    props: object
    ) => React.ReactNode | - | | 1.19 | + +### ValueItem + +```typescript +export type ValueItem = string | number | boolean; +``` + +### CheckboxData + +```typescript +export type CheckboxData = { + value: ValueItem; + label?: React.ReactNode; + disabled?: boolean; + [propName: string]: unknown; +}; +``` ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---- | :------- | +| 按键 | 说明 | +| :---- | :--------------- | | SPACE | 选择或取消当前项 | diff --git a/components/checkbox/__tests__/a11y-spec.js b/components/checkbox/__tests__/a11y-spec.js deleted file mode 100644 index 56cf65c678..0000000000 --- a/components/checkbox/__tests__/a11y-spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Checkbox from '../index'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Checkbox A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations for grouped checkbox with children', async () => { - wrapper = await testReact( - - - 苹果 - - - 梨子 - - - 西瓜 - - - ); - return wrapper; - }); - - it('should not have any violations for grouped checkbox with datasource', async () => { - const list = [ - { - value: 'apple', - label: '苹果', - }, - { - value: 'pear', - label: '梨', - }, - { - value: 'orange', - label: '橙子', - }, - ]; - wrapper = await testReact(); - return wrapper; - }); -}); diff --git a/components/checkbox/__tests__/a11y-spec.tsx b/components/checkbox/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..9836823891 --- /dev/null +++ b/components/checkbox/__tests__/a11y-spec.tsx @@ -0,0 +1,39 @@ +import React from 'react'; +import Checkbox from '../index'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +describe('Checkbox A11y', () => { + it('should not have any violations for grouped checkbox with children', async () => { + await testReact( + + + 苹果 + + + 梨子 + + + 西瓜 + + + ); + }); + + it('should not have any violations for grouped checkbox with datasource', async () => { + const list = [ + { + value: 'apple', + label: '苹果', + }, + { + value: 'pear', + label: '梨', + }, + { + value: 'orange', + label: '橙子', + }, + ]; + await testReact(); + }); +}); diff --git a/components/checkbox/__tests__/group-spec.js b/components/checkbox/__tests__/group-spec.js deleted file mode 100644 index 6fcd49c884..0000000000 --- a/components/checkbox/__tests__/group-spec.js +++ /dev/null @@ -1,236 +0,0 @@ -import React from 'react'; -import Enzyme, { shallow, mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import sinon from 'sinon'; -import assert from 'power-assert'; -import Checkbox from '../index'; - -/* eslint-disable */ -Enzyme.configure({ adapter: new Adapter() }); - -const CheckboxGroup = Checkbox.Group; - -describe('Checkbox.Group', () => { - let list; - beforeEach('mock data', () => { - list = [ - { - value: 'apple', - label: '苹果', - }, - { - value: 'pear', - label: '梨', - }, - { - value: 'orange', - label: '橙子', - }, - ]; - }); - describe('[render] control', () => { - it('should contain `pear`', () => { - const wrapper = shallow().dive(); - assert(wrapper.state().value.indexOf('pear') !== -1); - }); - - it('should have three children with mock data', () => { - const wrapper = mount(); - assert(wrapper.find('.next-checkbox-group').children().length === 3); - }); - - it('should support null child', () => { - const wrapper = mount( - - 1 - 2 - {null} - - ); - assert(wrapper.find('.next-checkbox-group').children().length === 2); - }); - }); - - describe('[render] uncontrol', () => { - it('should have three children with mock data', () => { - const wrapper = mount(); - assert(wrapper.find('.next-checkbox-group').children().length === 3); - }); - }); - - describe('[render] nest', () => { - const wrapper = shallow( - - - 苹果 - - - 梨子 - - - 西瓜 - - - ).dive(); - - it('should contain `pear` and `watermelon`', () => { - assert(wrapper.state().value.indexOf('pear') !== -1); - assert(wrapper.state().value.indexOf('watermelon') !== -1); - }); - - it('should have two children with nest ', () => { - const wrapper = mount( - - - 苹果 - - - 梨子 - - - 西瓜 - - - ); - const target = wrapper.find('.next-checkbox-group'); - assert(target.children().length === 3); - assert(target.find('.disabled').length === 1); - }); - }); - - describe('[events] simulate change', () => { - it('should call `onChange`', () => { - const onChange = sinon.spy(); - const wrapper = mount(); - wrapper - .find('input') - .first() - .simulate('change'); - assert(onChange.calledOnce); - - const onChange1 = sinon.spy(); - const wrapper1 = mount(); - wrapper1 - .find('input') - .first() - .simulate('change'); - assert(onChange.calledOnce); - }); - }); - - describe('[behavior] controlled', () => { - it('should support controlled `value`', () => { - const wrapper = shallow().dive(); - assert(wrapper.state().value[0] === 'pear'); - - wrapper.setProps({ - value: ['apple'], - }); - assert(wrapper.state().value[0] === 'apple'); - wrapper.setProps({ - value: 'orange', - }); - assert(wrapper.state().value[0] === 'orange'); - - wrapper.setProps({ - value: null, - }); - assert(wrapper.state().value.length === 0); - }); - - it('should support controlled `disabled`', () => { - const wrapper = mount(); - assert(!wrapper.props().disabled); - assert(!wrapper.find('.next-checkbox-group').hasClass('disabled')); - - wrapper.setProps({ - disabled: true, - }); - assert(wrapper.find('.next-checkbox-group').hasClass('disabled')); - }); - }); - describe('value === undefined', () => { - it('should support value === undefined', () => { - const wrapper = shallow(); - const wrapper1 = shallow(); - wrapper.setProps({ - value: undefined, - }); - assert.deepEqual(wrapper.dive().state().value, []); - assert.deepEqual(wrapper1.dive().state().value, []); - }); - }); - describe('value === 0', () => { - it('should support value === 0', () => { - const wrapper = shallow(); - assert.deepEqual(wrapper.dive().state().value, [0]); - wrapper.setProps({ - value: 1, - }); - assert.deepEqual(wrapper.dive().state().value, [1]); - }); - }); - - describe("should respect children's indeternimate state", () => { - it('should support value === 0', () => { - const wrapper1 = mount( - - 1 - - ); - const wrapper2 = mount( - - 1 - - ); - - assert(wrapper1.find('.indeterminate').length === 1); - assert(wrapper2.find('.indeterminate').length === 1); - }); - }); - - describe('render in preview mode', () => { - it('should isPreview', () => { - const wrapper = mount(); - assert(wrapper.getDOMNode().innerText === '苹果'); - }); - - it('should renderPreview', () => { - const wrapper = mount( - 'checkbox preview'} defaultValue={0} dataSource={list} /> - ); - assert(wrapper.getDOMNode().innerText === 'checkbox preview'); - }); - }); - it('value support bool`', () => { - let value = null; - const wrapper = mount( - { - value = v; - }} - dataSource={[ - { - value: false, - label: '苹果', - }, - { - value: true, - label: '橙子', - }, - ]} - /> - ); - - wrapper - .find('input') - .at(1) - .simulate('change'); - assert.deepEqual(value, [true]); - wrapper - .find('input') - .at(0) - .simulate('change'); - assert.deepEqual(value, [true, false]); - }); -}); diff --git a/components/checkbox/__tests__/group-spec.tsx b/components/checkbox/__tests__/group-spec.tsx new file mode 100644 index 0000000000..79a034cb63 --- /dev/null +++ b/components/checkbox/__tests__/group-spec.tsx @@ -0,0 +1,236 @@ +import React, { type ReactElement } from 'react'; +import { type MountReturn } from 'cypress/react'; +import Checkbox from '../index'; + +const CheckboxGroup = Checkbox.Group; + +const list = [ + { + value: 'apple', + label: '苹果', + }, + { + value: 'pear', + label: '梨', + }, + { + value: 'orange', + label: '橙子', + }, +]; + +describe('Checkbox.Group', () => { + describe('[render] control', () => { + it('should contain `pear`', () => { + cy.mount(); + cy.get('.next-checkbox-wrapper.checked').should('have.text', '梨'); + }); + + it('should have three children with mock data', () => { + cy.mount(); + cy.get('.next-checkbox-wrapper').should('have.length', 3); + }); + + it('should support null child', () => { + cy.mount( + + 1 + 2 + {null} + + ); + cy.get('.next-checkbox-wrapper').should('have.length', 2); + }); + }); + + describe('[render] uncontrolled', () => { + it('should have three children with mock data', () => { + cy.mount(); + cy.get('.next-checkbox-wrapper').should('have.length', 3); + }); + }); + + describe('[render] nest', () => { + it('should contain `pear` and `watermelon`', () => { + cy.mount( + + + 苹果 + + + 梨子 + + + 西瓜 + + + ); + cy.get('.next-checkbox-wrapper.checked').eq(0).should('have.text', '梨子'); + cy.get('.next-checkbox-wrapper.checked').eq(1).should('have.text', '西瓜'); + }); + + it('should have two children with nest ', () => { + cy.mount( + + + 苹果 + + + 梨子 + + + 西瓜 + + + ); + cy.get('.next-checkbox-wrapper').should('have.length', 3); + cy.get('.next-checkbox-wrapper.disabled').should('have.length', 1); + }); + }); + + describe('[events] simulate change', () => { + it('should call `onChange`', () => { + // const onChange = sinon.spy(); + const onChange = cy.spy().as('onChange'); + cy.mount(); + cy.get('input').eq(0).click(); + cy.get('@onChange').should('be.calledOnce'); + + const onChange1 = cy.spy().as('onChange1'); + cy.mount(); + cy.get('input').eq(0).click(); + cy.get('@onChange1').should('be.calledOnce'); + }); + }); + + describe('[behavior] controlled', () => { + it('should support controlled `value`', () => { + cy.mount().as('Demo'); + + cy.get('.next-checkbox-wrapper.checked').should('have.text', '梨'); + + cy.get('@Demo').then(({ component, rerender }) => { + return rerender( + React.cloneElement(component as ReactElement, { value: ['apple'] }) + ); + }); + + cy.get('.next-checkbox-wrapper.checked').should('have.text', '苹果'); + + cy.get('@Demo').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as ReactElement, { value: 'orange' })); + }); + + cy.get('.next-checkbox-wrapper.checked').should('have.text', '橙子'); + + cy.get('@Demo').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as ReactElement, { value: null })); + }); + + cy.get('.next-checkbox-wrapper.checked').should('have.length', 0); + }); + + it('should support controlled `disabled`', () => { + cy.mount().as( + 'Demo' + ); + + cy.get('.next-checkbox-group').should('not.have.class', 'disabled'); + + cy.get('@Demo').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as ReactElement, { disabled: true })); + }); + + cy.get('.next-checkbox-group').should('have.class', 'disabled'); + }); + }); + describe('value === undefined', () => { + it('should support value === undefined', () => { + cy.mount().as('Demo'); + cy.get('@Demo').then(({ component, rerender }) => { + return rerender( + React.cloneElement(component as ReactElement, { value: undefined }) + ); + }); + cy.get('.next-checkbox-wrapper.checked').should('have.length', 0); + cy.mount(); + cy.get('.next-checkbox-wrapper.checked').should('have.length', 0); + }); + }); + describe('value === 0', () => { + it('should support value === 0', () => { + cy.mount( + + ).as('Demo'); + cy.get('.next-checkbox-wrapper.checked').should('have.text', 0); + cy.get('@Demo').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as ReactElement, { value: 1 })); + }); + cy.get('.next-checkbox-wrapper.checked').should('have.text', 1); + }); + }); + + describe("should respect children's indeterminate state", () => { + it('should support value === 0', () => { + cy.mount( + + 1 + + ); + cy.get('.indeterminate').should('have.length', 1); + cy.mount( + + 1 + + ); + cy.get('.indeterminate').should('have.length', 1); + }); + }); + + describe('render in preview mode', () => { + it('should isPreview', () => { + cy.mount(); + cy.get('.next-form-preview').should('have.text', '苹果'); + }); + + it('should renderPreview', () => { + cy.mount( + 'checkbox preview'} + defaultValue={0} + dataSource={list} + /> + ); + cy.get('.next-form-preview').should('have.text', 'checkbox preview'); + }); + }); + it('value support bool`', () => { + const handleChange = cy.spy().as('handleChange'); + cy.mount( + + ); + cy.get('input').eq(1).click(); + cy.get('@handleChange').should('be.calledWith', [true]); + cy.get('input').eq(0).click(); + cy.get('@handleChange').should('be.calledWith', [true, false]); + }); +}); diff --git a/components/checkbox/__tests__/index-spec.js b/components/checkbox/__tests__/index-spec.js deleted file mode 100644 index 8674ff7722..0000000000 --- a/components/checkbox/__tests__/index-spec.js +++ /dev/null @@ -1,126 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import sinon from 'sinon'; -import assert from 'power-assert'; -import Checkbox from '../index'; - -/* eslint-disable */ -Enzyme.configure({ adapter: new Adapter() }); - -describe('Checkbox', () => { - describe('[render] normal', () => { - it('should get a normal checkbox', () => { - const wrapper1 = mount(); - const wrapper2 = mount(香蕉); - assert(wrapper1.find('.next-checkbox').length === 1); - assert(wrapper2.find('.next-checkbox').length === 1); - assert(wrapper2.find('input#banana').length === 1); - }); - }); - - describe('[render] checked', () => { - it('should get a checked checkbox', () => { - const wrapper1 = mount(); - const wrapper2 = mount(); - assert(wrapper1.find('.checked').length === 1); - assert(wrapper2.find('.checked').length === 1); - }); - }); - - describe('[render] indeterminate', () => { - it('should get a indeterminate checkbox', () => { - const wrapper1 = mount(); - const wrapper2 = mount(); - - assert(wrapper1.find('.indeterminate').length === 1); - assert(wrapper2.find('.indeterminate').length === 1); - }); - }); - - describe('[render] disabled', () => { - it('should get a disabled checkbox', () => { - const wrapper = mount(); - assert(wrapper.find('.disabled').length === 1); - }); - }); - - describe('[render] label', () => { - it('should get a checkbox with label', () => { - const wrapper = mount(); - assert(wrapper.find('.next-checkbox-label').length === 1); - }); - }); - - describe('[attribute] set custom `className`', () => { - it('should has className `cumstom-name`', () => { - const wrapper = mount(); - assert(wrapper.props().className === 'cumstom-name'); - assert(wrapper.find('.next-checkbox-wrapper.cumstom-name').length === 1); - }); - }); - - describe('[events] simulate click', () => { - const wrapper = mount(); - - it('should checked after click', () => { - wrapper.find('input').simulate('change', { target: { checked: true } }); - assert(wrapper.find('input').prop('checked')); - }); - it('should call `onChange`', () => { - const onChange = sinon.spy(); - const wrapper = mount(); - wrapper.find('input').simulate('change'); - assert(onChange.calledOnce); - }); - it('should return the passed value', () => { - const onChange = sinon.spy(); - const wrapper = mount(); - wrapper.find('input').simulate('change'); - assert(onChange.getCalls()[0].args[1].target.value === 'banana'); - }); - it('should call `onMouseEnter`', () => { - const onMouseEnter = sinon.spy(); - const wrapper1 = mount(); - wrapper1.find('.next-checkbox-wrapper').simulate('mouseEnter'); - assert(onMouseEnter.calledOnce); - }); - it('should call `onMouseLeave`', () => { - const onMouseLeave = sinon.spy(); - const wrapper1 = mount(); - wrapper1.find('.next-checkbox-wrapper').simulate('mouseLeave'); - assert(onMouseLeave.calledOnce); - }); - }); - - describe('[behavior] controlled', () => { - it('should support controlled `checked` and `indeterminate`', () => { - const wrapper = mount(); - assert(wrapper.find('input').props().checked); - assert(wrapper.find('.checked').length === 1); - - wrapper.setProps({ - checked: false, - }); - assert(!wrapper.find('input').props().checked); - assert(wrapper.find('.checked').length === 0); - wrapper.setProps({ - indeterminate: true, - }); - assert(wrapper.find('.indeterminate').length === 1); - }); - }); - - describe('render in preview mode', () => { - it('should isPreview', () => { - const wrapper = mount(); - assert(wrapper.getDOMNode().innerText === 'apple'); - }); - - it('should renderPreview', () => { - const wrapper = mount( 'checked'} />); - assert(wrapper.getDOMNode().innerText === 'checked'); - }); - }); -}); diff --git a/components/checkbox/__tests__/index-spec.tsx b/components/checkbox/__tests__/index-spec.tsx new file mode 100644 index 0000000000..e49e3b7781 --- /dev/null +++ b/components/checkbox/__tests__/index-spec.tsx @@ -0,0 +1,132 @@ +import React, { type ReactElement } from 'react'; +import { type MountReturn } from 'cypress/react'; +import Checkbox from '../index'; + +describe('Checkbox', () => { + describe('[render] normal', () => { + it('should get a normal checkbox', () => { + cy.mount(); + cy.get('.next-checkbox').should('have.length', 1); + cy.mount(香蕉); + cy.get('.next-checkbox').should('have.length', 1); + cy.get('input#banana').should('have.length', 1); + }); + }); + + describe('[render] checked', () => { + it('should get a checked checkbox', () => { + cy.mount(); + cy.get('.checked').should('have.length', 1); + cy.mount(); + cy.get('.checked').should('have.length', 1); + }); + }); + + describe('[render] indeterminate', () => { + it('should get a indeterminate checkbox', () => { + cy.mount(); + cy.get('.indeterminate').should('have.length', 1); + cy.mount(); + cy.get('.indeterminate').should('have.length', 1); + }); + }); + + describe('[render] disabled', () => { + it('should get a disabled checkbox', () => { + cy.mount(); + cy.get('.disabled').should('have.length', 1); + }); + }); + + describe('[render] label', () => { + it('should get a checkbox with label', () => { + cy.mount(); + cy.get('.next-checkbox-label').should('have.length', 1); + }); + }); + + describe('[attribute] set custom `className`', () => { + it('should has className `custom-name`', () => { + cy.mount(); + cy.get('.next-checkbox-wrapper.custom-name').should('exist'); + }); + }); + + describe('[events] simulate click', () => { + it('should checked after click', () => { + cy.mount(); + cy.get('input').eq(0).check(); + cy.get('input').should('have.prop', 'checked', true); + }); + it('should call `onChange`', () => { + const onChange = cy.spy().as('onChange'); + cy.mount(); + cy.get('input').eq(0).check(); + cy.get('@onChange').should('be.called'); + }); + it('should return the passed value', () => { + const onChange = cy.spy().as('onChange'); + cy.mount( + { + e.persist(); + onChange(e); + }} + value="banana" + /> + ); + cy.get('input').eq(0).check(); + cy.get('@onChange').should( + 'be.calledWithMatch', + (e: React.ChangeEvent) => { + return e.target.value === 'banana'; + } + ); + }); + it('should call `onMouseEnter`', () => { + const onMouseEnter = cy.spy().as('onMouseEnter'); + cy.mount(); + // React 的 mouseEnter 事件是通过监听 mouseover 实现的 + cy.get('.next-checkbox-wrapper').trigger('mouseover'); + cy.get('@onMouseEnter').should('be.calledOnce'); + }); + it('should call `onMouseLeave`', () => { + const onMouseLeave = cy.spy().as('onMouseLeave'); + cy.mount(); + // React 的 mouseLeave 事件是通过监听 mouseout 实现的 + cy.get('.next-checkbox-wrapper').trigger('mouseout'); + cy.get('@onMouseLeave').should('be.calledOnce'); + }); + }); + + describe('[behavior] controlled', () => { + it('should support controlled `checked` and `indeterminate`', () => { + cy.mount().as('Demo'); + cy.get('input').should('be.checked'); + cy.get('.checked').should('have.length', 1); + cy.get('@Demo').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as ReactElement, { checked: false })); + }); + cy.get('input').should('not.be.checked'); + cy.get('.checked').should('have.length', 0); + cy.get('@Demo').then(({ component, rerender }) => { + return rerender( + React.cloneElement(component as ReactElement, { indeterminate: true }) + ); + }); + cy.get('.indeterminate').should('have.length', 1); + }); + }); + + describe('render in preview mode', () => { + it('should isPreview', () => { + cy.mount(); + cy.get('.next-form-preview').should('have.text', 'apple'); + }); + + it('should renderPreview', () => { + cy.mount( 'checked'} />); + cy.get('.next-form-preview').should('have.text', 'checked'); + }); + }); +}); diff --git a/components/checkbox/checkbox-group.jsx b/components/checkbox/checkbox-group.jsx deleted file mode 100644 index ea2f56a964..0000000000 --- a/components/checkbox/checkbox-group.jsx +++ /dev/null @@ -1,240 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import { polyfill } from 'react-lifecycles-compat'; -import { obj } from '../util'; -import Checkbox from './checkbox'; - -const { pickOthers } = obj; - -/** Checkbox.Group */ -class CheckboxGroup extends Component { - static propTypes = { - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 自定义类名 - */ - className: PropTypes.string, - /** - * 自定义内敛样式 - */ - style: PropTypes.object, - /** - * 整体禁用 - */ - disabled: PropTypes.bool, - /** - * 可选项列表, 数据项可为 String 或者 Object, 如 `['apple', 'pear', 'orange']` 或者 `[{value: 'apple', label: '苹果',}, {value: 'pear', label: '梨'}, {value: 'orange', label: '橙子'}]` - */ - dataSource: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.arrayOf(PropTypes.object)]), - /** - * 被选中的值列表 - */ - value: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number, PropTypes.bool]), - /** - * 默认被选中的值列表 - */ - defaultValue: PropTypes.oneOfType([PropTypes.array, PropTypes.string, PropTypes.number, PropTypes.bool]), - /** - * 通过子元素方式设置内部 checkbox - */ - children: PropTypes.arrayOf(PropTypes.element), - /** - * 选中值改变时的事件 - * @param {Array} value 选中项列表 - * @param {Event} e Dom 事件对象 - */ - onChange: PropTypes.func, - - /** - * 子项目的排列方式 - * - hoz: 水平排列 (default) - * - ver: 垂直排列 - */ - direction: PropTypes.oneOf(['hoz', 'ver']), - /** - * 是否为预览态 - * @version 1.19 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {Array} previewed 预览值 [{label: '', value:''},...] - * @param {Object} props 所有传入的参数 - * @returns {reactNode} Element 渲染内容 - * @version 1.19 - */ - renderPreview: PropTypes.func, - }; - - static defaultProps = { - dataSource: [], - onChange: () => {}, - prefix: 'next-', - direction: 'hoz', - isPreview: false, - }; - - static childContextTypes = { - onChange: PropTypes.func, - __group__: PropTypes.bool, - selectedValue: PropTypes.array, - disabled: PropTypes.bool, - }; - - constructor(props) { - super(props); - - let value = []; - if ('value' in props) { - value = props.value; - } else if ('defaultValue' in props) { - value = props.defaultValue; - } - if (!Array.isArray(value)) { - if (value === null || value === undefined) { - value = []; - } else { - value = [value]; - } - } - this.state = { - value: [...value], - }; - - this.onChange = this.onChange.bind(this); - } - - getChildContext() { - return { - __group__: true, - onChange: this.onChange, - selectedValue: this.state.value, - disabled: this.props.disabled, - }; - } - - static getDerivedStateFromProps(nextProps) { - if ('value' in nextProps) { - let { value } = nextProps; - if (!Array.isArray(value)) { - if (value === null || value === undefined) { - value = []; - } else { - value = [value]; - } - } - - return { value }; - } - - return null; - } - - onChange(currentValue, e) { - const { value } = this.state; - const index = value.indexOf(currentValue); - const valTemp = [...value]; - - if (index === -1) { - valTemp.push(currentValue); - } else { - valTemp.splice(index, 1); - } - - if (!('value' in this.props)) { - this.setState({ value: valTemp }); - } - this.props.onChange(valTemp, e); - } - - render() { - const { className, style, prefix, disabled, direction, rtl, isPreview, renderPreview } = this.props; - const others = pickOthers(CheckboxGroup.propTypes, this.props); - - // 如果内嵌标签跟dataSource同时存在,以内嵌标签为主 - let children; - const previewed = []; - if (this.props.children) { - children = React.Children.map(this.props.children, child => { - if (!React.isValidElement(child)) { - return child; - } - const checked = this.state.value && this.state.value.indexOf(child.props.value) > -1; - - if (checked) { - previewed.push({ - label: child.props.children, - value: child.props.value, - }); - } - - return React.cloneElement(child, child.props.rtl === undefined ? { rtl } : null); - }); - } else { - children = this.props.dataSource.map((item, index) => { - let option = item; - if (typeof item !== 'object') { - option = { - label: item, - value: item, - disabled, - }; - } - const checked = this.state.value && this.state.value.indexOf(option.value) > -1; - - if (checked) { - previewed.push({ - label: option.label, - value: option.value, - }); - } - - return ( - - ); - }); - } - - if (isPreview) { - const previewCls = classnames(className, `${prefix}form-preview`); - - if ('renderPreview' in this.props) { - return ( -
    - {renderPreview(previewed, this.props)} -
    - ); - } - - return ( -

    - {previewed.map(item => item.label).join(', ')} -

    - ); - } - - const cls = classnames({ - [`${prefix}checkbox-group`]: true, - [`${prefix}checkbox-group-${direction}`]: true, - [className]: !!className, - disabled, - }); - - return ( - - {children} - - ); - } -} - -export default polyfill(CheckboxGroup); diff --git a/components/checkbox/checkbox-group.tsx b/components/checkbox/checkbox-group.tsx new file mode 100644 index 0000000000..6892a7d224 --- /dev/null +++ b/components/checkbox/checkbox-group.tsx @@ -0,0 +1,224 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { polyfill } from 'react-lifecycles-compat'; +import { obj } from '../util'; +import Checkbox from './checkbox'; +import type { CheckboxData, GroupProps, GroupState, ValueItem } from './types'; + +const { pickOthers } = obj; + +/** Checkbox.Group */ +class CheckboxGroup extends React.Component { + static displayName = 'CheckboxGroup'; + + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + className: PropTypes.string, + style: PropTypes.object, + disabled: PropTypes.bool, + dataSource: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.object), + ]), + value: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + defaultValue: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string, + PropTypes.number, + PropTypes.bool, + ]), + children: PropTypes.arrayOf(PropTypes.element), + onChange: PropTypes.func, + direction: PropTypes.oneOf(['hoz', 'ver']), + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + }; + + static defaultProps = { + dataSource: [], + onChange: () => {}, + prefix: 'next-', + direction: 'hoz', + isPreview: false, + }; + + static childContextTypes = { + onChange: PropTypes.func, + __group__: PropTypes.bool, + selectedValue: PropTypes.array, + disabled: PropTypes.bool, + }; + + constructor(props: GroupProps) { + super(props); + + let value: GroupProps['value'] = []; + if ('value' in props) { + value = props.value; + } else if ('defaultValue' in props) { + value = props.defaultValue; + } + if (!Array.isArray(value)) { + if (value === null || value === undefined) { + value = []; + } else { + value = [value]; + } + } + this.state = { + value: [...value], + }; + + this.onChange = this.onChange.bind(this); + } + + getChildContext() { + return { + __group__: true, + onChange: this.onChange, + selectedValue: this.state.value, + disabled: this.props.disabled, + }; + } + + static getDerivedStateFromProps(nextProps: GroupProps) { + if ('value' in nextProps) { + let { value } = nextProps; + if (!Array.isArray(value)) { + if (value === null || value === undefined) { + value = []; + } else { + value = [value]; + } + } + return { value }; + } + + return null; + } + + onChange(currentValue: ValueItem, event: React.ChangeEvent) { + const { value } = this.state; + const index = value.indexOf(currentValue); + const valTemp = [...value]; + + if (index === -1) { + valTemp.push(currentValue); + } else { + valTemp.splice(index, 1); + } + + if (!('value' in this.props)) { + this.setState({ value: valTemp }); + } + this.props.onChange?.(valTemp, event); + } + + render() { + const { className, style, prefix, disabled, direction, rtl, isPreview, renderPreview } = + this.props; + const others = pickOthers(CheckboxGroup.propTypes, this.props); + + // 如果内嵌标签跟 dataSource 同时存在,以内嵌标签为主 + let children; + const previewed: { + label: string | React.ReactNode; + value: string | React.ReactNode; + }[] = []; + if (this.props.children) { + children = React.Children.map(this.props.children, child => { + if ( + !React.isValidElement<{ + value: ValueItem; + children?: string; + rtl?: boolean; + }>(child) + ) { + return child; + } + const checked = + this.state.value && this.state.value.indexOf(child.props?.value) > -1; + + if (checked) { + previewed.push({ + label: child.props?.children, + value: child.props?.value, + }); + } + + return React.cloneElement(child, child.props?.rtl === undefined ? { rtl } : {}); + }); + } else { + children = this.props.dataSource?.map((item, index) => { + let option: CheckboxData; + if (typeof item !== 'object') { + option = { + label: item, + value: item, + disabled, + }; + } else { + option = item; + } + const checked = this.state.value && this.state.value.indexOf(option.value) > -1; + + if (checked) { + previewed.push({ + label: option.label, + value: option.value, + }); + } + + return ( + + ); + }); + } + + if (isPreview) { + const previewCls = classnames(className, `${prefix}form-preview`); + + if ('renderPreview' in this.props) { + return ( +
    + {renderPreview?.(previewed, this.props)} +
    + ); + } + + return ( +

    + {previewed.map(item => item.label).join(', ')} +

    + ); + } + + const cls = classnames(className, { + [`${prefix}checkbox-group`]: true, + [`${prefix}checkbox-group-${direction}`]: true, + disabled, + }); + + return ( + + {children} + + ); + } +} + +export default polyfill(CheckboxGroup); diff --git a/components/checkbox/checkbox.jsx b/components/checkbox/checkbox.jsx deleted file mode 100644 index 241cb30e47..0000000000 --- a/components/checkbox/checkbox.jsx +++ /dev/null @@ -1,305 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import { polyfill } from 'react-lifecycles-compat'; -import UIState from '../mixin-ui-state'; -import ConfigProvider from '../config-provider'; -import Icon from '../icon'; -import withContext from './with-context'; -import { obj, func } from '../util'; - -const noop = func.noop; -function isChecked(selectedValue, value) { - return selectedValue.indexOf(value) > -1; -} -/** - * Checkbox - * @order 1 - */ -class Checkbox extends UIState { - static displayName = 'Checkbox'; - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 自定义类名 - */ - className: PropTypes.string, - /** - * checkbox id, 挂载在input上 - */ - id: PropTypes.string, - /** - * 自定义内敛样式 - */ - style: PropTypes.object, - /** - * 选中状态 - */ - checked: PropTypes.bool, - /** - * 默认选中状态 - */ - defaultChecked: PropTypes.bool, - /** - * 禁用 - */ - disabled: PropTypes.bool, - /** - * 通过属性配置label, - */ - label: PropTypes.node, - /** - * Checkbox 的中间状态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 - */ - indeterminate: PropTypes.bool, - /** - * Checkbox 的默认中间态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 - */ - defaultIndeterminate: PropTypes.bool, - /** - * 状态变化时触发的事件 - * @param {Boolean} checked 是否选中 - * @param {Event} e Dom 事件对象 - */ - onChange: PropTypes.func, - /** - * 鼠标进入enter事件 - * @param {Event} e Dom 事件对象 - */ - onMouseEnter: PropTypes.func, - /** - * 鼠标离开Leave事件 - * @param {Event} e Dom 事件对象 - */ - onMouseLeave: PropTypes.func, - /** - * checkbox 的value - */ - value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), - /** - * name - */ - name: PropTypes.string, - /** - * 是否为预览态 - * @version 1.19 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {Boolean} checked 是否选中 - * @param {Object} props 所有传入的参数 - * @returns {reactNode} Element 渲染内容 - * @version 1.19 - */ - renderPreview: PropTypes.func, - }; - - static defaultProps = { - defaultChecked: false, - defaultIndeterminate: false, - onChange: noop, - onMouseEnter: noop, - onMouseLeave: noop, - prefix: 'next-', - isPreview: false, - }; - - constructor(props) { - super(props); - const { context } = props; - let checked, indeterminate; - - if ('checked' in props) { - checked = props.checked; - } else { - checked = props.defaultChecked; - } - - if ('indeterminate' in props) { - indeterminate = props.indeterminate; - } else { - indeterminate = props.defaultIndeterminate; - } - if (context.__group__) { - checked = isChecked(context.selectedValue, props.value); - } - this.state = { - checked, - indeterminate, - }; - - this.onChange = this.onChange.bind(this); - } - - static getDerivedStateFromProps(nextProps) { - const { context: nextContext } = nextProps; - const state = {}; - if (nextContext.__group__) { - if ('selectedValue' in nextContext) { - state.checked = isChecked(nextContext.selectedValue, nextProps.value); - } - } else if ('checked' in nextProps) { - state.checked = nextProps.checked; - } - - if ('indeterminate' in nextProps) { - state.indeterminate = nextProps.indeterminate; - } - - return state; - } - - get disabled() { - const { props } = this; - const { context } = props; - - return props.disabled || ('disabled' in context && context.disabled); - } - - shouldComponentUpdate(nextProps, nextState, nextContext) { - const { shallowEqual } = obj; - return ( - !shallowEqual(this.props, nextProps) || - !shallowEqual(this.state, nextState) || - !shallowEqual(this.context, nextContext) - ); - } - - onChange(e) { - const { context, value } = this.props; - const checked = e.target.checked; - - if (this.disabled) { - return; - } - if (context.__group__) { - context.onChange(value, e); - } else { - if (!('checked' in this.props)) { - this.setState({ - checked: checked, - }); - } - - if (!('indeterminate' in this.props)) { - this.setState({ - indeterminate: false, - }); - } - this.props.onChange(checked, e); - } - } - - render() { - /* eslint-disable no-unused-vars */ - const { - id, - className, - children, - style, - label, - onMouseEnter, - onMouseLeave, - rtl, - isPreview, - renderPreview, - context, - value, - name, - ...otherProps - } = this.props; - const checked = !!this.state.checked; - const disabled = this.disabled; - const indeterminate = !!this.state.indeterminate; - const prefix = context.prefix || this.props.prefix; - - const others = obj.pickOthers(Checkbox.propTypes, otherProps); - const othersData = obj.pickAttrsWith(others, 'data-'); - if (otherProps.title) { - othersData.title = otherProps.title; - } - - let childInput = ( - - ); - - // disable 无状态操作 - if (!disabled) { - childInput = this.getStateElement(childInput); - } - const cls = classnames({ - [`${prefix}checkbox-wrapper`]: true, - [className]: !!className, - checked, - disabled, - indeterminate, - [this.getStateClassName()]: true, - }); - const labelCls = `${prefix}checkbox-label`; - const type = indeterminate ? 'semi-select' : 'select'; - - if (isPreview) { - const previewCls = classnames(className, `${prefix}form-preview`); - if ('renderPreview' in this.props) { - return ( -
    - {renderPreview(checked, this.props)} -
    - ); - } - - return ( -

    - {checked && (children || label || this.state.value)} -

    - ); - } - - const iconCls = classnames({ - zoomIn: indeterminate, - [`${prefix}checkbox-semi-select-icon`]: indeterminate, - [`${prefix}checkbox-select-icon`]: !indeterminate, - }); - - return ( - - ); - } -} - -export default ConfigProvider.config(withContext(polyfill(Checkbox))); diff --git a/components/checkbox/checkbox.tsx b/components/checkbox/checkbox.tsx new file mode 100644 index 0000000000..d88a844404 --- /dev/null +++ b/components/checkbox/checkbox.tsx @@ -0,0 +1,275 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import classnames from 'classnames'; +import { polyfill } from 'react-lifecycles-compat'; +import UIState, { type UIStateState } from '../mixin-ui-state'; +import ConfigProvider from '../config-provider'; +import Icon from '../icon'; +import withCheckboxContext, { type CheckboxContext } from './with-context'; +import { obj, func } from '../util'; +import type { CheckboxProps } from './types'; + +const noop = func.noop; +function isChecked( + selectedValue: CheckboxContext['selectedValue'], + value: CheckboxProps['value'] +): boolean { + return selectedValue.indexOf(value) > -1; +} + +interface CheckboxState extends UIStateState { + value?: CheckboxProps['value']; + checked?: boolean; + indeterminate?: boolean; +} + +export interface PrivateCheckboxProps extends CheckboxProps { + context: CheckboxContext; +} + +/** + * Checkbox + * @order 1 + */ +class Checkbox extends UIState { + static displayName = 'Checkbox'; + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + className: PropTypes.string, + id: PropTypes.string, + style: PropTypes.object, + checked: PropTypes.bool, + defaultChecked: PropTypes.bool, + disabled: PropTypes.bool, + label: PropTypes.node, + indeterminate: PropTypes.bool, + defaultIndeterminate: PropTypes.bool, + onChange: PropTypes.func, + onMouseEnter: PropTypes.func, + onMouseLeave: PropTypes.func, + value: PropTypes.oneOfType([PropTypes.string, PropTypes.number, PropTypes.bool]), + name: PropTypes.string, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + }; + + static defaultProps = { + defaultChecked: false, + defaultIndeterminate: false, + onChange: noop, + onMouseEnter: noop, + onMouseLeave: noop, + prefix: 'next-', + isPreview: false, + }; + + constructor(props: PrivateCheckboxProps) { + super(props); + const { context } = props; + let checked, indeterminate; + + if ('checked' in props) { + checked = props.checked; + } else { + checked = props.defaultChecked; + } + + if ('indeterminate' in props) { + indeterminate = props.indeterminate; + } else { + indeterminate = props.defaultIndeterminate; + } + if (context.__group__) { + checked = isChecked(context.selectedValue, props.value); + } + this.state = { + checked, + indeterminate, + }; + + this.onChange = this.onChange.bind(this); + } + + static getDerivedStateFromProps(nextProps: PrivateCheckboxProps) { + const { context: nextContext } = nextProps; + const state: CheckboxState = {}; + if (nextContext.__group__) { + if ('selectedValue' in nextContext) { + state.checked = isChecked(nextContext.selectedValue, nextProps.value); + } + } else if ('checked' in nextProps) { + state.checked = nextProps.checked; + } + + if ('indeterminate' in nextProps) { + state.indeterminate = nextProps.indeterminate; + } + + return state; + } + + get disabled() { + const { props } = this; + const { context } = props; + + return props.disabled || ('disabled' in context && context.disabled); + } + + shouldComponentUpdate( + nextProps: PrivateCheckboxProps, + nextState: CheckboxState, + nextContext: CheckboxContext + ) { + const { shallowEqual } = obj; + return ( + !shallowEqual(this.props, nextProps) || + !shallowEqual(this.state, nextState) || + !shallowEqual(this.context, nextContext) + ); + } + + onChange(event: React.ChangeEvent) { + const { context, value } = this.props; + const checked = event.target.checked; + + if (this.disabled) { + return; + } + if (context.__group__) { + context.onChange(value, event); + } else { + if (!('checked' in this.props)) { + this.setState({ + checked: checked, + }); + } + + if (!('indeterminate' in this.props)) { + this.setState({ + indeterminate: false, + }); + } + this.props.onChange?.(checked, event); + } + } + + render() { + /* eslint-disable no-unused-vars */ + const { + id, + className, + children, + style, + label, + onMouseEnter, + onMouseLeave, + rtl, + isPreview, + renderPreview, + context, + value, + name, + ...otherProps + } = this.props; + const checked = !!this.state.checked; + const disabled = this.disabled; + const indeterminate = !!this.state.indeterminate; + const prefix = context.prefix || this.props.prefix; + + const others = obj.pickOthers(Checkbox.propTypes, otherProps); + const othersData = obj.pickAttrsWith(others, 'data-') as ReturnType< + typeof obj.pickAttrsWith + > & { title?: string }; + if (otherProps.title) { + othersData.title = otherProps.title; + } + + let childInput = ( + + ); + + // disable 无状态操作 + if (!disabled) { + childInput = this.getStateElement(childInput); + } + const cls = classnames(className, { + [`${prefix}checkbox-wrapper`]: true, + checked, + disabled, + indeterminate, + [this.getStateClassName()]: true, + }); + const labelCls = `${prefix}checkbox-label`; + const type = indeterminate ? 'semi-select' : 'select'; + + if (isPreview) { + const previewCls = classnames(className, `${prefix}form-preview`); + if ('renderPreview' in this.props) { + return ( +
    + {renderPreview?.(checked, this.props)} +
    + ); + } + + return ( +

    + {checked && (children || label || this.state.value)} +

    + ); + } + + const iconCls = classnames({ + zoomIn: indeterminate, + [`${prefix}checkbox-semi-select-icon`]: indeterminate, + [`${prefix}checkbox-select-icon`]: !indeterminate, + }); + + return ( + + ); + } +} + +export default ConfigProvider.config( + withCheckboxContext(polyfill(Checkbox) as React.ComponentType) +); diff --git a/components/checkbox/index.d.ts b/components/checkbox/index.d.ts deleted file mode 100644 index 950a4df4fa..0000000000 --- a/components/checkbox/index.d.ts +++ /dev/null @@ -1,168 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -type data = { - value?: string | number | boolean; - label?: React.ReactNode; - disabled?: boolean; - [propName: string]: any; -}; - -export type CheckboxData = data; - -export interface GroupProps extends HTMLAttributesWeak, CommonProps { - /** - * 自定义类名 - */ - className?: string; - - /** - * 自定义内敛样式 - */ - style?: React.CSSProperties; - - /** - * 整体禁用 - */ - disabled?: boolean; - - /** - * 是否为预览态 - */ - isPreview?: boolean; - - renderPreview?: (checked: boolean, props: object) => React.ReactNode; - - /** - * 可选项列表, 数据项可为 String 或者 Object, 如 `['apple', 'pear', 'orange']` 或者 `[{value: 'apple', label: '苹果',}, {value: 'pear', label: '梨'}, {value: 'orange', label: '橙子'}]` - */ - dataSource?: Array | Array | Array; - - /** - * 被选中的值列表 - */ - value?: Array | Array | Array | string | number | boolean; - - /** - * 默认被选中的值列表 - */ - defaultValue?: Array | Array | Array | string | number | boolean; - - /** - * name - */ - name?: string; - - /** - * 通过子元素方式设置内部 checkbox - */ - children?: Array; - - /** - * 选中值改变时的事件 - */ - onChange?: (value: Array | Array | Array, e: any) => void; - - /** - * 子项目的排列方式 - * - hoz: 水平排列 (default) - * - ver: 垂直排列 - */ - direction?: 'hoz' | 'ver'; - itemDirection?: 'hoz' | 'ver'; -} - -export class Group extends React.Component {} -interface HTMLAttributesWeak extends React.HTMLAttributes { - onChange?: any; - onMouseEnter?: any; - onMouseLeave?: any; -} - -export interface CheckboxProps extends HTMLAttributesWeak, CommonProps { - /** - * 自定义类名 - */ - className?: string; - - /** - * checkbox id, 挂载在input上 - */ - id?: string; - - /** - * 自定义内敛样式 - */ - style?: React.CSSProperties; - - /** - * 选中状态 - */ - checked?: boolean; - - /** - * checkbox 的value - */ - value?: string | number | boolean; - - /** - * name - */ - name?: string; - - /** - * 默认选中状态 - */ - defaultChecked?: boolean; - - /** - * 禁用 - */ - disabled?: boolean; - - /** - * 通过属性配置label, - */ - label?: React.ReactNode; - - /** - * Checkbox 的中间状态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 - */ - indeterminate?: boolean; - - /** - * Checkbox 的默认中间态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 - */ - defaultIndeterminate?: boolean; - - /** - * 是否为预览态 - */ - isPreview?: boolean; - - /** - * 状态变化时触发的事件 - */ - onChange?: (checked: boolean, e: any) => void; - - /** - * 鼠标进入enter事件 - */ - onMouseEnter?: (e: React.MouseEvent) => void; - - /** - * 鼠标离开Leave事件 - */ - onMouseLeave?: (e: React.MouseEvent) => void; -} - -export default class Checkbox extends React.Component { - static Group: typeof Group; -} diff --git a/components/checkbox/index.jsx b/components/checkbox/index.jsx deleted file mode 100644 index 37c0c44f0b..0000000000 --- a/components/checkbox/index.jsx +++ /dev/null @@ -1,18 +0,0 @@ -import Checkbox from './checkbox'; -import Group from './checkbox-group'; -import ConfigProvider from '../config-provider'; - -Checkbox.Group = ConfigProvider.config(Group, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('itemDirection' in props) { - deprecated('itemDirection', 'direction', 'Checkbox'); - const { itemDirection, ...others } = props; - - props = { direction: itemDirection, ...others }; - } - - return props; - }, -}); - -export default Checkbox; diff --git a/components/checkbox/index.tsx b/components/checkbox/index.tsx new file mode 100644 index 0000000000..afb8076fe9 --- /dev/null +++ b/components/checkbox/index.tsx @@ -0,0 +1,23 @@ +import Checkbox from './checkbox'; +import Group from './checkbox-group'; +import ConfigProvider from '../config-provider'; +import { assignSubComponent } from '../util/component'; + +const CheckboxWithGroup = assignSubComponent(Checkbox, { + Group: ConfigProvider.config(Group, { + transform: /* istanbul ignore next */ (props, deprecated) => { + if ('itemDirection' in props) { + deprecated('itemDirection', 'direction', 'Checkbox'); + const { itemDirection, ...others } = props; + + props = { direction: itemDirection, ...others }; + } + + return props; + }, + }), +}); + +export type { CheckboxProps, GroupProps, CheckboxData, ValueItem } from './types'; + +export default CheckboxWithGroup; diff --git a/components/checkbox/mobile/index.jsx b/components/checkbox/mobile/index.tsx similarity index 100% rename from components/checkbox/mobile/index.jsx rename to components/checkbox/mobile/index.tsx diff --git a/components/checkbox/style.js b/components/checkbox/style.ts similarity index 100% rename from components/checkbox/style.js rename to components/checkbox/style.ts diff --git a/components/checkbox/types.ts b/components/checkbox/types.ts new file mode 100644 index 0000000000..c6cc61e414 --- /dev/null +++ b/components/checkbox/types.ts @@ -0,0 +1,237 @@ +import type * as React from 'react'; +import { type CommonProps } from '../util'; + +interface HTMLAttributesWeak + extends Omit, 'onChange' | 'defaultValue'> {} + +/** + * @api + */ +export type ValueItem = string | number | boolean; + +/** + * @api + */ +export type CheckboxData = { + value: ValueItem; + label?: React.ReactNode; + disabled?: boolean; + [propName: string]: unknown; +}; + +/** + * @api Checkbox.Group + */ +export interface GroupProps extends HTMLAttributesWeak, CommonProps { + /** + * 自定义类名 + * @en Custom className + */ + className?: string; + + /** + * 自定义内联样式 + * @en Custom inline style + */ + style?: React.CSSProperties; + + /** + * 整体禁用 + * @en Entirely disabled + */ + disabled?: boolean; + + /** + * 可选项列表 + * @en Option list + * @remarks + * 数据项可为 String 或者 Object, 如 `['apple', 'pear', 'orange']` 或者 `[{value: 'apple', label: '苹果',}, {value: 'pear', label: '梨'}, {value: 'orange', label: '橙子'}]` + * - + * Data item can be String or Object, such as `['apple', 'pear', 'orange']` or `[{value: 'apple', label: 'Apple',}, {value: 'pear', label: 'Pear'}, {value: 'orange', label: 'Orange'}]` + */ + dataSource?: Array | Array; + + /** + * 被选中的值列表 + * @en Selected value list + */ + value?: ValueItem[] | ValueItem; + + /** + * 默认被选中的值列表 + * @en Default selected value list + */ + defaultValue?: ValueItem[] | ValueItem; + + /** + * name + * @en name + */ + name?: string; + + /** + * 通过子元素方式设置内部 checkbox + * @en Set internal checkbox through child elements + */ + children?: React.ReactNode; + + /** + * 选中值改变时的事件 + * @en Selected value change event + */ + onChange?: (value: ValueItem[], e: React.ChangeEvent) => void; + + /** + * 子项目的排列方式 + * @en Arrangement of subitems + * @remarks + * hoz: 水平排列 (default), + * ver: 垂直排列 + * - + * hoz: Horizontal arrangement (default), + * ver: Vertical arrangement + */ + direction?: 'hoz' | 'ver'; + /** + * [废弃] 子项目的排列方式 + * @en [Deprecated] Arrangement of subitems + * @deprecated Use `direction` instead + */ + itemDirection?: 'hoz' | 'ver'; + + /** + * 是否为预览态 + * @en Is preview + * @version 1.19 + */ + isPreview?: boolean; + + /** + * 预览态模式下渲染的内容 + * @en Custom rendering content + * @version 1.19 + * @param previewed - 预览值 [\{label: '', value:''\},...] - Previewed value [\{label: '', value:''\},...] + * @param props - 所有传入的参数 - All props + * @returns 定制渲染内容 - Custom rendering content + */ + renderPreview?: ( + previewed: { + label: string | React.ReactNode; + value: string | React.ReactNode; + }[], + props: object + ) => React.ReactNode; +} + +export interface GroupState { + value: ValueItem[]; +} + +/** + * @api Checkbox + */ +export interface CheckboxProps extends HTMLAttributesWeak, CommonProps { + /** + * 自定义类名 + * @en className + */ + className?: string; + + /** + * checkbox id, 挂载在 input 上 + * @en Checkbox id, mounted on the input + */ + id?: string; + + /** + * 自定义内联样式 + * @en Custom inline style + */ + style?: React.CSSProperties; + + /** + * 选中状态 + * @en Checked status + */ + checked?: boolean; + + /** + * checkbox 的 value + * @en Checkbox value + */ + value?: ValueItem; + + /** + * name + * @en name + */ + name?: string; + + /** + * 默认选中状态 + * @en Default checked status + * @defaultValue false + */ + defaultChecked?: boolean; + + /** + * 禁用 + * @en Disabled + */ + disabled?: boolean; + + /** + * 通过属性配置 label, + * @en Label + */ + label?: React.ReactNode; + + /** + * Checkbox 的中间状态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 + * @en Checkbox middle status, only affects the style of Checkbox, and does not affect its checked property + */ + indeterminate?: boolean; + + /** + * Checkbox 的默认中间态,只会影响到 Checkbox 的样式,并不影响其 checked 属性 + * @en Checkbox default middle status, only affects the style of Checkbox, and does not affect its checked property + * @defaultValue false + */ + defaultIndeterminate?: boolean; + + /** + * 状态变化时触发的事件 + * @en Status change event + */ + onChange?: (checked: boolean, e: React.ChangeEvent) => void; + + /** + * 鼠标进入 enter 事件 + * @en Mouse enter event + */ + onMouseEnter?: (e: React.MouseEvent) => void; + + /** + * 鼠标离开 Leave 事件 + * @en Mouse leave event + */ + onMouseLeave?: (e: React.MouseEvent) => void; + + /** + * 是否为预览态 + * @en Is preview + * @defaultValue false + * @version 1.19 + */ + isPreview?: boolean; + + /** + * 预览态模式下渲染的内容 + * @en Custom rendering content + * @version 1.19 + * @param checked - 是否选中 - Is checked + * @param props - 所有传入的参数 - All props + * @returns 定制渲染内容 - Custom rendering content + */ + renderPreview?: (checked: boolean, props: CheckboxProps) => React.ReactNode; +} diff --git a/components/checkbox/with-context.jsx b/components/checkbox/with-context.jsx deleted file mode 100644 index a5b5857bec..0000000000 --- a/components/checkbox/with-context.jsx +++ /dev/null @@ -1,19 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; - -export default function withContext(Checkbox) { - return class WrappedComp extends React.Component { - static displayName = 'Checkbox'; - static contextTypes = { - onChange: PropTypes.func, - __group__: PropTypes.bool, - selectedValue: PropTypes.array, - disabled: PropTypes.bool, - prefix: PropTypes.string, - }; - - render() { - return ; - } - }; -} diff --git a/components/checkbox/with-context.tsx b/components/checkbox/with-context.tsx new file mode 100644 index 0000000000..261229f91d --- /dev/null +++ b/components/checkbox/with-context.tsx @@ -0,0 +1,34 @@ +import * as React from 'react'; +import * as PropTypes from 'prop-types'; +import { type PrivateCheckboxProps } from './checkbox'; +import { type CheckboxProps } from './types'; + +export interface CheckboxContext { + onChange: ( + value: string | number | boolean | undefined, + event: React.ChangeEvent + ) => void; + __group__: boolean; + selectedValue: CheckboxProps['value'][]; + disabled: boolean; + prefix: string; +} + +export default function withCheckboxContext( + Checkbox: React.ComponentType +): React.ComponentType { + return class WrappedComp extends React.Component { + static displayName = 'Checkbox'; + static contextTypes = { + onChange: PropTypes.func, + __group__: PropTypes.bool, + selectedValue: PropTypes.array, + disabled: PropTypes.bool, + prefix: PropTypes.string, + }; + + render() { + return ; + } + }; +} diff --git a/components/collapse/__docs__/adaptor/index.jsx b/components/collapse/__docs__/adaptor/index.jsx deleted file mode 100644 index e08d5473db..0000000000 --- a/components/collapse/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,50 +0,0 @@ -import React from 'react'; -import { Collapse } from '@alifd/next'; -import { Types, parseData, NodeType } from '@alifd/adaptor-helper'; - -export default { - name: 'Collapse', - editor: () => ({ - props: [{ - name: 'state', - label: 'Status', - type: Types.enum, - options: ['normal', 'disabled'], - default: 'normal' - }, { - name: 'width', - type: Types.number, - default: 400 - }], - data: { - active: true, - disable: true, - default: '*Panel Header 1\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\nPanel Header 2\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\nPanel Header 3\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\n' - } - }), - adaptor: ({ state, width, data, style = {}, ...others }) => { - const list = parseData(data).filter(node => NodeType.node === node.type); - let expandedKeys = []; - const children = list.map(({ state, value, children }, index) => { - if (state === 'active') { - expandedKeys.push(`panel_${index}`); - } - - return ( - - {children && children.length > 0 ? children[0].value : ''} - - ); - }); - return ( - - { - children - } - - ); - } -}; diff --git a/components/collapse/__docs__/adaptor/index.tsx b/components/collapse/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..f1b861ff51 --- /dev/null +++ b/components/collapse/__docs__/adaptor/index.tsx @@ -0,0 +1,69 @@ +import React from 'react'; +import { Collapse } from '@alifd/next'; +import { Types, parseData, NodeType } from '@alifd/adaptor-helper'; +import type { INode } from '@alifd/adaptor-helper/types/parse-data'; + +interface AdaptorProps { + state: string; + width: number; + data: string; + style: React.CSSProperties; +} + +export default { + name: 'Collapse', + editor: () => ({ + props: [ + { + name: 'state', + label: 'Status', + type: Types.enum, + options: ['normal', 'disabled'], + default: 'normal', + }, + { + name: 'width', + type: Types.number, + default: 400, + }, + ], + data: { + active: true, + disable: true, + default: + '*Panel Header 1\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\nPanel Header 2\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\nPanel Header 3\n\tPeople always make mistakes, frustrated, nerve-racking, but cannot remain stagnant.\n', + }, + }), + adaptor: ({ state, width, data, style = {}, ...others }: AdaptorProps) => { + const list = (parseData(data) as INode[]).filter(node => NodeType.node === node.type); + const expandedKeys = [] as string[]; + const children = list.map(({ state, value, children }, index) => { + if (state === 'active') { + expandedKeys.push(`panel_${index}`); + } + + return ( + + {children && children.length > 0 ? children[0].value : ''} + + ); + }); + return ( + + {children} + + ); + }, +}; diff --git a/components/collapse/__docs__/demo/basic/index.tsx b/components/collapse/__docs__/demo/basic/index.tsx index 4a14ca6280..849bd70715 100644 --- a/components/collapse/__docs__/demo/basic/index.tsx +++ b/components/collapse/__docs__/demo/basic/index.tsx @@ -1,9 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Collapse, Radio } from '@alifd/next'; +import { Collapse } from '@alifd/next'; const Panel = Collapse.Panel; -const RadioGroup = Radio.Group; ReactDOM.render( diff --git a/components/collapse/__docs__/demo/event/index.tsx b/components/collapse/__docs__/demo/event/index.tsx index 7788aec2ff..d952406973 100644 --- a/components/collapse/__docs__/demo/event/index.tsx +++ b/components/collapse/__docs__/demo/event/index.tsx @@ -5,25 +5,22 @@ import { Collapse } from '@alifd/next'; const Panel = Collapse.Panel; class Demo extends React.Component { - constructor(props, context) { - super(props, context); - this.state = { - expandedKeys: [], - }; - } + state = { + expandedKeys: [], + }; - onExpand(expandedKeys) { + onExpand = (expandedKeys: string[]) => { this.setState({ expandedKeys, }); - } + }; - onClick(key) { + onClick = (key: any) => { console.log('clicked', key); - } + }; render() { return ( - + Promotions are marketing campaigns ran by Marketplace. Participate to sale your products during that promotion and make a profit diff --git a/components/collapse/__docs__/index.en-us.md b/components/collapse/__docs__/index.en-us.md index a586d1abdd..0229bb8de1 100644 --- a/components/collapse/__docs__/index.en-us.md +++ b/components/collapse/__docs__/index.en-us.md @@ -12,29 +12,52 @@ ### When to use When some earas may toggle between collapse state and expand state. + ## API ### Collapse -| Param | Description | Type | Default Value | -| ------------------- | -------------------------------------------------- | -------- | --------- | -| dataSource | data model | Array | - | -| defaultExpandedKeys | default expand panel keys | Array | - | -| expandedKeys | expand panel keys | Array | - | -| onExpand | callback when panel state changes

    **signature**:
    Function() => void | Function | func.noop | -| disabled | disable all panel | Boolean | - | -| accordion | accordion mode, you can only open at most one panel | Boolean | false | +| Param | Description | Type | Default Value | Required | +| ------------------- | ------------------------------------------------ | -------------------------------------------- | ------------- | -------- | +| dataSource | Use data model to build | Array\ | - | | +| defaultExpandedKeys | Default expanded keys | KeyType[] | - | | +| expandedKeys | Controlled expanded keys | KeyType[] | - | | +| onExpand | Callback when the expanded state changes | (expandedKeys: KeyType \| KeyType[]) => void | - | | +| disabled | All disabled | boolean | - | | +| accordion | Accordion mode, only one can be opened at a time | boolean | false | | ### Collapse.Panel -| Param | Description | Type | Default Value | -| -------- | -------- | --------- | --- | -| disabled | disable this panel | Boolean | - | -| title | panel title | ReactNode | - | +| Param | Description | Type | Default Value | Required | +| ---------- | ------------------------------- | -------------------------------------------------------------------------------------------------- | ------------- | -------- | +| disabled | Whether to disable user actions | boolean | - | | +| title | Title | React.ReactNode | - | | +| isExpanded | Whether to expand | boolean | false | | +| onClick | Click callback function | \| ((e: React.MouseEvent\ \| React.KeyboardEvent\) => void)
    \| null | - | | + +### KeyType + +```typescript +export type KeyType = string | number; +``` + +### DataItem + +```typescript +export type DataItem = { + id?: string; + title?: React.ReactNode; + content?: React.ReactNode; + disabled?: boolean; + key?: KeyType; + onClick?: (key: KeyType) => void; + [propName: string]: unknown; +}; +``` ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| Tab | navigate to the next collapse panel | -| Space | toggle expanded | \ No newline at end of file +| KeyBoard | Descripiton | +| :------- | :---------------------------------- | +| Tab | navigate to the next collapse panel | +| Space | toggle expanded | diff --git a/components/collapse/__docs__/index.md b/components/collapse/__docs__/index.md index 6b4101809a..10ad3793ea 100644 --- a/components/collapse/__docs__/index.md +++ b/components/collapse/__docs__/index.md @@ -19,25 +19,47 @@ ### Collapse -| 参数 | 说明 | 类型 | 默认值 | -| ------------------- | ----------------------------------------------------- | -------- | --------- | -| dataSource | 使用数据模型构建 | Array | - | -| defaultExpandedKeys | 默认展开keys | Array | - | -| expandedKeys | 受控展开keys | Array | - | -| onExpand | 展开状态发升变化时候的回调

    **签名**:
    Function() => void | Function | func.noop | -| disabled | 所有禁用 | Boolean | - | -| accordion | 手风琴模式,一次只能打开一个 | Boolean | false | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------------------- | ---------------------------- | -------------------------------------------- | ------ | -------- | +| dataSource | 使用数据模型构建 | Array\ | - | | +| defaultExpandedKeys | 默认展开 keys | KeyType[] | - | | +| expandedKeys | 受控展开 keys | KeyType[] | - | | +| onExpand | 展开状态发升变化时候的回调 | (expandedKeys: KeyType \| KeyType[]) => void | - | | +| disabled | 所有禁用 | boolean | - | | +| accordion | 手风琴模式,一次只能打开一个 | boolean | false | | ### Collapse.Panel -| 参数 | 说明 | 类型 | 默认值 | -| -------- | -------- | --------- | --- | -| disabled | 是否禁止用户操作 | Boolean | - | -| title | 标题 | ReactNode | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ---------- | ---------------- | -------------------------------------------------------------------------------------------------- | ------ | -------- | +| disabled | 是否禁止用户操作 | boolean | - | | +| title | 标题 | React.ReactNode | - | | +| isExpanded | 是否展开 | boolean | false | | +| onClick | 点击回调函数 | \| ((e: React.MouseEvent\ \| React.KeyboardEvent\) => void)
    \| null | - | | + +### KeyType + +```typescript +export type KeyType = string | number; +``` + +### DataItem + +```typescript +export type DataItem = { + id?: string; + title?: React.ReactNode; + content?: React.ReactNode; + disabled?: boolean; + key?: KeyType; + onClick?: (key: KeyType) => void; + [propName: string]: unknown; +}; +``` ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---- | :------------------- | -| Tab | 切换到下一个collapse panel | -| Space | 切换collapse的折叠状态 | +| 按键 | 说明 | +| :---- | :-------------------------- | +| Tab | 切换到下一个 collapse panel | +| Space | 切换 collapse 的折叠状态 | diff --git a/components/collapse/__docs__/theme/index.jsx b/components/collapse/__docs__/theme/index.jsx deleted file mode 100644 index ffbbf893f7..0000000000 --- a/components/collapse/__docs__/theme/index.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import '../../style'; -import Collapse from '../../index'; - -// import component - -const Panel = Collapse.Panel; - -const i18nMap = { - 'zh-cn': { - title: '这是区块的标题', - content: '人总是要犯错误、受挫折、伤脑筋的,不过决不能停滞不前;应该完成的任务,即使为它牺牲生命,也要完成。社会之河的圣水就是因为被一股永不停滞的激流推动向前才得以保持洁净。这意味着河岸偶尔也会被冲垮,短时间造成损失,可是如果怕河堤溃决,便设法永远堵死这股激流,那只会招致停滞和死亡。' - }, - 'en-us': { - title: 'Panel Header ', - content: 'People always make mistakes, frustrated, nerve-racking, but cannot remain stagnant; should finish the task, even if it\'s life, but also to complete. Society of holy water because the river is a never-ending stream of pushing forward was able to keep clean. This means that sometimes river was washed away, causing short-term losses, but if the fear of embankments break, they managed to always blocked this torrent, it will only lead to stagnation and death.' - } -}; - -function render(i18n) { - const title = i18n.title; - const content = i18n.content; - return ReactDOM.render(( -
    -

    手风琴 Collapse

    - - - - {/**/} - - {title}1
    {title}1
    {title}1
    }> - {content} -
    - - {content} - {/* --------- this is for config platform ----------- */} -
    -
    -
    - {/* --------- this is for config platform ----------- */} - - - {content} - - - {content} - - - - - - - {content} - - - {content} - - - {content} - - - {content} - - - - -
    - ), document.getElementById('container')); -} - -window.renderDemo = function (lang) { - render(i18nMap[lang]); -}; - -window.renderDemo('en-us'); - -initDemo('collapse'); diff --git a/components/collapse/__docs__/theme/index.tsx b/components/collapse/__docs__/theme/index.tsx new file mode 100644 index 0000000000..2d61a46775 --- /dev/null +++ b/components/collapse/__docs__/theme/index.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; +import '../../style'; +import Collapse from '../../index'; + +interface i18nContent { + title: string; + content: string; +} + +const Panel = Collapse.Panel; + +const i18nMap = { + 'zh-cn': { + title: '这是区块的标题', + content: + '人总是要犯错误、受挫折、伤脑筋的,不过决不能停滞不前;应该完成的任务,即使为它牺牲生命,也要完成。社会之河的圣水就是因为被一股永不停滞的激流推动向前才得以保持洁净。这意味着河岸偶尔也会被冲垮,短时间造成损失,可是如果怕河堤溃决,便设法永远堵死这股激流,那只会招致停滞和死亡。', + }, + 'en-us': { + title: 'Panel Header ', + content: + "People always make mistakes, frustrated, nerve-racking, but cannot remain stagnant; should finish the task, even if it's life, but also to complete. Society of holy water because the river is a never-ending stream of pushing forward was able to keep clean. This means that sometimes river was washed away, causing short-term losses, but if the fear of embankments break, they managed to always blocked this torrent, it will only lead to stagnation and death.", + }, +} as { [key: string]: i18nContent }; + +function render(i18n: i18nContent) { + const title = i18n.title; + const content = i18n.content; + ReactDOM.render( +
    +

    手风琴 Collapse

    + + + + {/**/} + + + {title}1
    + {title}1
    + {title}1 +
    + } + > + {content} +
    + + {content} + {/* --------- this is for config platform ----------- */} +
    + {/* @ts-expect-error div has no type */} +
    +
    + {/* --------- this is for config platform ----------- */} + + {content} + {content} + + + + + {content} + {content} + {content} + {content} + + + +
    , + document.getElementById('container') + ); +} + +(window as any).renderDemo = function (lang: string) { + render(i18nMap[lang]); +}; + +window.renderDemo('en-us'); + +initDemo('collapse'); diff --git a/components/collapse/__tests__/a11y-spec.js b/components/collapse/__tests__/a11y-spec.js deleted file mode 100644 index ee0d18d3a3..0000000000 --- a/components/collapse/__tests__/a11y-spec.js +++ /dev/null @@ -1,53 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Collapse from '../index'; -import '../style'; -import { unmount, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -const Panel = Collapse.Panel; - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Collapse A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations for children rendered component', async () => { - wrapper = await testReact( - - Pannel Content - Pannel Content -
    others
    -
    - ); - return wrapper; - }); - - it('should not have any violations for data rendered component', async () => { - const list = [ - { - title: 'Well, hello there', - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - wrapper = await testReact(, { - incomplete: true, - }); - return wrapper; - }); -}); diff --git a/components/collapse/__tests__/a11y-spec.tsx b/components/collapse/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..de56cadcba --- /dev/null +++ b/components/collapse/__tests__/a11y-spec.tsx @@ -0,0 +1,36 @@ +import React from 'react'; +import Collapse from '../index'; +import '../style'; +import { testReact } from '../../util/__tests__/a11y/validate'; + +const Panel = Collapse.Panel; + +describe('Collapse A11y', () => { + it('should not have any violations for children rendered component', async () => { + await testReact( + + Pannel Content + Pannel Content +
    others
    +
    + ); + }); + + it('should not have any violations for data rendered component', async () => { + const list = [ + { + title: 'Well, hello there', + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + await testReact(, { + incomplete: true, + }); + }); +}); diff --git a/components/collapse/__tests__/index-spec.js b/components/collapse/__tests__/index-spec.js deleted file mode 100644 index 71454c88bc..0000000000 --- a/components/collapse/__tests__/index-spec.js +++ /dev/null @@ -1,471 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import sinon from 'sinon'; -import Collapse from '../index'; - -Enzyme.configure({ adapter: new Adapter() }); - -const Panel = Collapse.Panel; - -/* global describe, it */ -/* eslint-disable react/jsx-filename-extension */ - -describe('Collapse', () => { - describe('render', () => { - it('[normal] Should render null', () => { - const wrapper = mount(); - assert(wrapper.find(Collapse).length === 1); - }); - it('[normal] Should render from children', () => { - const wrapper = mount( - - Pannel Content - Pannel Content -
    others
    -
    - ); - assert(wrapper.find(Collapse).length === 1); - assert(wrapper.find(Panel).length === 2); - }); - - it('hidden panel should be hidden', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - - ); - const el = wrapper.find('.next-collapse-panel-hidden'); - assert(el.length === 2); - }); - - it('Should render from dataSource', () => { - const list = [ - { - title: 'Well, hello there', - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - const wrapper = mount(); - assert(wrapper.find(Panel).length === 2); - }); - - it('should default expand keys passed in `defaultExpandedKeys`', () => { - const wrapper = mount( - - Pannel Content - Pannel Content -
    others
    -
    - ); - assert(wrapper.find(Collapse).length === 1); - assert(wrapper.find(Panel).length === 2); - }); - }); - - describe('defaultExpandedKeys', () => { - describe('default mode', () => { - it('should expand panel with string key', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - const el = wrapper.find('.next-collapse-panel').at(2); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should expand panel with number key', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - const el = wrapper.find('.next-collapse-panel').at(2); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should close default expanded string keys', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - wrapper - .find('.next-collapse-panel-title') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - - it('should close default expanded number keys', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - wrapper - .find('.next-collapse-panel-title') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - - it('should open default expanded datasource using number keys', () => { - const list = [ - { - title: 'Well, hello there', - key: 0, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - key: 1, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - const wrapper = mount(); - const el = wrapper.find('.next-collapse-panel').at(1); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should close default expanded datasource using number keys on click', () => { - const list = [ - { - title: 'Well, hello there', - key: 0, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - key: 1, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - const wrapper = mount(); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - }); - - describe('accordian mode', () => { - it('should expand panel with string key', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - const el = wrapper.find('.next-collapse-panel').at(2); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should expand panel with number key', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - const el = wrapper.find('.next-collapse-panel').at(2); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should close default expanded string keys', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - wrapper - .find('.next-collapse-panel-title') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - - it('should close default expanded number keys', () => { - const wrapper = mount( - - Pannel Content - Pannel Content - Pannel Content - Pannel Content - -
    others
    -
    - ); - wrapper - .find('.next-collapse-panel-title') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - - it('should open default expanded datasource using number keys', () => { - const list = [ - { - title: 'Well, hello there', - key: 0, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - key: 1, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - const wrapper = mount(); - const el = wrapper.find('.next-collapse-panel').at(1); - assert(el.hasClass('next-collapse-panel-expanded')); - }); - - it('should close default expanded datasource using number keys on click', () => { - const list = [ - { - title: 'Well, hello there', - key: 0, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - { - title: 'Well, hello there', - key: 1, - content: - 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', - }, - ]; - const wrapper = mount(); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - }); - }); - - describe('event', () => { - it('Should expanded by click', () => { - const collapse = ( - - Pannel Content1 - Pannel Content2 - Pannel Content3 - - ); - const wrapper = mount(collapse); - - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 2); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - }); - - it('Should expanded by space key', () => { - const collapse = ( - - Pannel Content1 - Pannel Content2 - Pannel Content3 - - ); - const wrapper = mount(collapse); - - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('keyDown', { keyCode: 32 }); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('keyDown', { keyCode: 32 }); - assert(wrapper.find('.next-collapse-panel-expanded').length === 2); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('keyDown', { keyCode: 32 }); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - }); - it('should support accordion', () => { - const collapse = ( - - Pannel Content1 - Pannel Content2 - Pannel Content3 - - ); - const wrapper = mount(collapse); - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - wrapper - .find('.next-collapse-panel-title') - .at(1) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - wrapper - .find('.next-collapse-panel-title') - .at(2) - .simulate('click'); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - }); - - it('disabled', () => { - const collapse = ( - - Pannel Content - - ); - const wrapper = mount(collapse); - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('click'); //模拟点击 - assert(wrapper.find('.next-collapse-panel-expanded').length === 0); - }); - - it('[onExpand] Call when the trigger', () => { - const onExpand = sinon.spy(); - - const collapse = ( - - Pannel Content1 - Pannel Content2 - - ); - const wrapper = mount(collapse); - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('click'); //模拟点击 - - assert(onExpand.calledOnce); - }); - - it('under Control', () => { - const collapse = ( - - Pannel Content1 - Pannel Content2 - - ); - const wrapper = mount(collapse); - wrapper - .find('.next-collapse-panel-title') - .first() - .simulate('click'); //模拟点击 - - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - }); - }); - describe('react api', () => { - it('calls componentWillReceiveProps', done => { - const collapse = ( - - Pannel Content - - ); - const wrapper = mount(collapse); - - wrapper.setProps({ expandedKeys: ['0'] }); - assert(wrapper.find('.next-collapse-panel-expanded').length === 1); - done(); - }); - }); - describe('panel', () => { - it('id should be auto add', done => { - const collapse = ( - - - Pannel Content - - - ); - const wrapper = mount(collapse); - assert(wrapper.find('#test-id-1-heading').length === 1); - assert(wrapper.find('#test-id-1-region').length === 1); - done(); - }); - it('all id should be auto add', done => { - const collapse = ( - - Pannel Content - Pannel Content - - ); - const wrapper = mount(collapse); - - assert(wrapper.find('.next-collapse#test-id-2').length === 1); - const panels = wrapper.find('.next-collapse-panel'); - assert(panels.length === 2); - assert(panels.at(0).getDOMNode().id); - assert(panels.at(1).getDOMNode().id); - done(); - }); - }); -}); diff --git a/components/collapse/__tests__/index-spec.tsx b/components/collapse/__tests__/index-spec.tsx new file mode 100644 index 0000000000..73cc7a4c03 --- /dev/null +++ b/components/collapse/__tests__/index-spec.tsx @@ -0,0 +1,426 @@ +import React, { type ReactElement } from 'react'; +import { CompareSnapshot } from '../../util/__tests__/common/snapshot'; +import Collapse from '../index'; +import '../style'; + +const Panel = Collapse.Panel; + +const collapseCompareSnapshot = new CompareSnapshot({ componentName: 'collapse' }); + +describe('Collapse', () => { + describe('render', () => { + it('[normal] Should render null', () => { + cy.mount(); + cy.get('.next-collapse').should('have.length', 1); + }); + it('[normal] Should render from children', () => { + cy.mount( + + Pannel Content + Pannel Content +
    others
    +
    + ); + cy.get('.next-collapse').should('have.length', 1); + cy.get('.next-collapse-panel').should('have.length', 2); + }); + + it('hidden panel should be hidden', () => { + cy.mount( + + Pannel Content + Pannel Content + + ); + cy.get('.next-collapse-panel-hidden').should('have.length', 2); + }); + + it('Should render from dataSource', () => { + const list = [ + { + title: 'Well, hello there', + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + cy.mount(); + cy.get('.next-collapse-panel').should('have.length', 2); + }); + + it('should default expand keys passed in `defaultExpandedKeys`', () => { + cy.mount( + + Pannel Content + Pannel Content +
    others
    +
    + ); + cy.get('.next-collapse').should('have.length', 1); + cy.get('.next-collapse-panel').should('have.length', 2); + }); + + it('should render with proper border-radius and overflow hidden', () => { + cy.mount( + + Pannel Content + Pannel Content + + ); + collapseCompareSnapshot.compare(cy.get('.next-collapse')); + }); + }); + + describe('defaultExpandedKeys', () => { + describe('default mode', () => { + it('should expand panel with string key', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel') + .eq(2) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should expand panel with number key', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel') + .eq(2) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded string keys', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel').eq(2).as('secondPanel'); + cy.get('@secondPanel').find('.next-collapse-panel-title').click(); + cy.get('@secondPanel').should('not.have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded number keys', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel').eq(2).as('secondPanel'); + cy.get('@secondPanel').find('.next-collapse-panel-title').click(); + cy.get('@secondPanel').should('not.have.class', 'next-collapse-panel-expanded'); + }); + + it('should open default expanded datasource using number keys', () => { + const list = [ + { + title: 'Well, hello there', + key: 0, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + key: 1, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + cy.mount(); + cy.get('.next-collapse-panel') + .eq(1) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded datasource using number keys on click', () => { + const list = [ + { + title: 'Well, hello there', + key: 0, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + key: 1, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + cy.mount(); + cy.get('.next-collapse-panel-title').eq(1).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 0); + }); + }); + + describe('accordian mode', () => { + it('should expand panel with string key', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel') + .eq(2) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should expand panel with number key', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel') + .eq(2) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded string keys', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel').eq(2).as('secondPanel'); + cy.get('@secondPanel').find('.next-collapse-panel-title').click(); + cy.get('@secondPanel').should('not.have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded number keys', () => { + cy.mount( + + Pannel Content + Pannel Content + Pannel Content + Pannel Content + +
    others
    +
    + ); + cy.get('.next-collapse-panel').eq(2).as('secondPanel'); + cy.get('@secondPanel').find('.next-collapse-panel-title').click(); + cy.get('@secondPanel').should('not.have.class', 'next-collapse-panel-expanded'); + }); + + it('should open default expanded datasource using number keys', () => { + const list = [ + { + title: 'Well, hello there', + key: 0, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + key: 1, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + cy.mount(); + + cy.get('.next-collapse-panel') + .eq(1) + .should('have.class', 'next-collapse-panel-expanded'); + }); + + it('should close default expanded datasource using number keys on click', () => { + const list = [ + { + title: 'Well, hello there', + key: 0, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + { + title: 'Well, hello there', + key: 1, + content: + 'Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.', + }, + ]; + cy.mount(); + cy.get('.next-collapse-panel-title').eq(1).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 0); + }); + }); + }); + + describe('event', () => { + it('Should expanded by click', () => { + const collapse = ( + + Pannel Content1 + Pannel Content2 + Pannel Content3 + + ); + cy.mount(collapse); + cy.get('.next-collapse-panel-title').first().click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + cy.get('.next-collapse-panel-title').eq(1).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 2); + cy.get('.next-collapse-panel-title').eq(1).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + }); + + it('Should expanded by space key', () => { + const collapse = ( + + Pannel Content1 + Pannel Content2 + Pannel Content3 + + ); + cy.mount(collapse); + + cy.get('.next-collapse-panel-title').first().trigger('keydown', { keyCode: 32 }); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + cy.get('.next-collapse-panel-title').eq(1).trigger('keydown', { keyCode: 32 }); + cy.get('.next-collapse-panel-expanded').should('have.length', 2); + cy.get('.next-collapse-panel-title').eq(1).trigger('keydown', { keyCode: 32 }); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + }); + it('should support accordion', () => { + const collapse = ( + + Pannel Content1 + Pannel Content2 + Pannel Content3 + + ); + cy.mount(collapse); + cy.get('.next-collapse-panel-title').first().click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + cy.get('.next-collapse-panel-title').eq(1).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + cy.get('.next-collapse-panel-title').eq(2).click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + }); + + it('disabled', () => { + const collapse = ( + + Pannel Content + + ); + cy.mount(collapse); + cy.get('.next-collapse-panel-title').first().click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 0); + }); + + it('[onExpand] Call when the trigger', () => { + const onExpand = cy.spy(); + const collapse = ( + + Pannel Content1 + Pannel Content2 + + ); + cy.mount(collapse); + cy.get('.next-collapse-panel-title').first().click(); + cy.wrap(onExpand).should('be.calledOnce'); + }); + + it('under Control', () => { + const collapse = ( + + Pannel Content1 + Pannel Content2 + + ); + cy.mount(collapse); + cy.get('.next-collapse-panel-title').first().click(); + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + }); + }); + describe('react api', () => { + it('calls componentWillReceiveProps', () => { + const collapse = ( + + Pannel Content + + ); + cy.mount(collapse).then(({ component, rerender }) => { + return rerender( + React.cloneElement(component as ReactElement, { expandedKeys: ['0'] }) + ); + }); + + cy.get('.next-collapse-panel-expanded').should('have.length', 1); + }); + }); + describe('panel', () => { + it('id should be auto add', () => { + const collapse = ( + + + Pannel Content + + + ); + cy.mount(collapse); + cy.get('#test-id-1-heading').should('have.length', 1); + cy.get('#test-id-1-region').should('have.length', 1); + }); + it('all id should be auto add', () => { + const collapse = ( + + Pannel Content + Pannel Content + + ); + cy.mount(collapse); + cy.get('.next-collapse#test-id-2').should('have.length', 1); + cy.get('.next-collapse-panel').should('have.length', 2); + cy.get('.next-collapse-panel').eq(0).should('have.attr', 'id'); + cy.get('.next-collapse-panel').eq(1).should('have.attr', 'id'); + }); + }); +}); diff --git a/components/collapse/__tests__/snapshots/__base__/index-spec.tsx/Collapse -- render -- should render with proper border-radius and overflow hidden.snap.png b/components/collapse/__tests__/snapshots/__base__/index-spec.tsx/Collapse -- render -- should render with proper border-radius and overflow hidden.snap.png new file mode 100644 index 0000000000..2c37c2a622 Binary files /dev/null and b/components/collapse/__tests__/snapshots/__base__/index-spec.tsx/Collapse -- render -- should render with proper border-radius and overflow hidden.snap.png differ diff --git a/components/collapse/collapse.jsx b/components/collapse/collapse.jsx deleted file mode 100644 index 241216de60..0000000000 --- a/components/collapse/collapse.jsx +++ /dev/null @@ -1,225 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import { polyfill } from 'react-lifecycles-compat'; -import ConfigProvider from '../config-provider'; -import { func, obj } from '../util'; -import Panel from './panel'; - -/** Collapse */ -class Collapse extends React.Component { - static propTypes = { - /** - * 样式前缀 - */ - prefix: PropTypes.string, - /** - * 组件接受行内样式 - */ - style: PropTypes.object, - /** - * 使用数据模型构建 - */ - dataSource: PropTypes.array, - /** - * 默认展开keys - */ - defaultExpandedKeys: PropTypes.array, - /** - * 受控展开keys - */ - expandedKeys: PropTypes.array, - /** - * 展开状态发升变化时候的回调 - */ - onExpand: PropTypes.func, - /** - * 所有禁用 - */ - disabled: PropTypes.bool, - /** - * 扩展class - */ - className: PropTypes.string, - /** - * 手风琴模式,一次只能打开一个 - */ - accordion: PropTypes.bool, - children: PropTypes.node, - id: PropTypes.string, - rtl: PropTypes.bool, - }; - - static defaultProps = { - accordion: false, - prefix: 'next-', - onExpand: func.noop, - }; - - static contextTypes = { - prefix: PropTypes.string, - }; - - constructor(props) { - super(props); - - let expandedKeys; - if ('expandedKeys' in props) { - expandedKeys = props.expandedKeys; - } else { - expandedKeys = props.defaultExpandedKeys; - } - - this.state = { - expandedKeys: typeof expandedKeys === 'undefined' ? [] : expandedKeys, - }; - } - - static getDerivedStateFromProps(props) { - if ('expandedKeys' in props) { - return { - expandedKeys: typeof props.expandedKeys === 'undefined' ? [] : props.expandedKeys, - }; - } - return null; - } - - onItemClick(key) { - let expandedKeys = this.state.expandedKeys; - if (this.props.accordion) { - expandedKeys = String(expandedKeys[0]) === String(key) ? [] : [key]; - } else { - expandedKeys = [...expandedKeys]; - const stringKey = String(key); - const index = expandedKeys.findIndex(k => String(k) === stringKey); - const isExpanded = index > -1; - if (isExpanded) { - expandedKeys.splice(index, 1); - } else { - expandedKeys.push(key); - } - } - this.setExpandedKey(expandedKeys); - } - - genratePanelId(itemId, index) { - const { id: collapseId } = this.props; - let id; - if (itemId) { - // 优先用 item自带的id - id = itemId; - } else if (collapseId) { - // 其次用 collapseId 和 index 生成id - id = `${collapseId}-panel-${index}`; - } - return id; - } - getProps(item, index, key) { - const expandedKeys = this.state.expandedKeys; - const { title } = item; - let disabled = this.props.disabled; - - if (!disabled) { - disabled = item.disabled; - } - - let isExpanded = false; - - if (this.props.accordion) { - isExpanded = String(expandedKeys[0]) === String(key); - } else { - isExpanded = expandedKeys.some(expandedKey => { - if (expandedKey === null || expandedKey === undefined || key === null || key === undefined) { - return false; - } - - if (expandedKey === key || expandedKey.toString() === key.toString()) { - return true; - } - return false; - }); - } - - const id = this.genratePanelId(item.id, index); - return { - key, - title, - isExpanded, - disabled, - id, - onClick: disabled - ? null - : () => { - this.onItemClick(key); - if ('onClick' in item) { - item.onClick(key); - } - }, - }; - } - - getItemsByDataSource() { - const { props } = this; - const { dataSource } = props; - // 是否有dataSource.item传入过key - const hasKeys = dataSource.some(item => 'key' in item); - - return dataSource.map((item, index) => { - // 传入过key就用item.key 没传入则统一使用index为key - const key = hasKeys ? item.key : `${index}`; - return ( - - {item.content} - - ); - }); - } - - getItemsByChildren() { - // 是否有child传入过key - const allKeys = React.Children.map(this.props.children, child => child && child.key); - const hasKeys = Boolean(allKeys && allKeys.length); - - return React.Children.map(this.props.children, (child, index) => { - if (child && typeof child.type === 'function' && child.type.isNextPanel) { - // 传入过key就用child.key 没传入则统一使用index为key - const key = hasKeys ? child.key : `${index}`; - return React.cloneElement(child, this.getProps(child.props, index, key)); - } else { - return child; - } - }); - } - - setExpandedKey(expandedKeys) { - if (!('expandedKeys' in this.props)) { - this.setState({ expandedKeys }); - } - this.props.onExpand(this.props.accordion ? expandedKeys[0] : expandedKeys); - } - - render() { - const { prefix, className, style, disabled, dataSource, id, rtl } = this.props; - const collapseClassName = classNames({ - [`${prefix}collapse`]: true, - [`${prefix}collapse-disabled`]: disabled, - [className]: Boolean(className), - }); - - const others = obj.pickOthers(Collapse.propTypes, this.props); - return ( - - ); - } -} - -export default polyfill(ConfigProvider.config(Collapse)); diff --git a/components/collapse/collapse.tsx b/components/collapse/collapse.tsx new file mode 100644 index 0000000000..23f918f440 --- /dev/null +++ b/components/collapse/collapse.tsx @@ -0,0 +1,218 @@ +import React, { type Key, type ReactElement } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import { polyfill } from 'react-lifecycles-compat'; +import ConfigProvider from '../config-provider'; +import { func, obj } from '../util'; +import Panel from './panel'; +import type { CollapseProps, DataItem, KeyType } from './types'; + +/** Collapse */ +class Collapse extends React.Component< + CollapseProps, + { + expandedKeys: KeyType[]; + } +> { + static propTypes = { + prefix: PropTypes.string, + style: PropTypes.object, + dataSource: PropTypes.array, + defaultExpandedKeys: PropTypes.array, + expandedKeys: PropTypes.array, + onExpand: PropTypes.func, + disabled: PropTypes.bool, + className: PropTypes.string, + accordion: PropTypes.bool, + children: PropTypes.node, + id: PropTypes.string, + rtl: PropTypes.bool, + }; + + static defaultProps = { + accordion: false, + prefix: 'next-', + onExpand: func.noop, + }; + + static contextTypes = { + prefix: PropTypes.string, + }; + + constructor(props: CollapseProps) { + super(props); + + let expandedKeys: KeyType[] | undefined; + if ('expandedKeys' in props) { + expandedKeys = props.expandedKeys; + } else { + expandedKeys = props.defaultExpandedKeys; + } + + this.state = { + expandedKeys: typeof expandedKeys === 'undefined' ? [] : expandedKeys, + }; + } + + static getDerivedStateFromProps(props: CollapseProps) { + if ('expandedKeys' in props) { + return { + expandedKeys: typeof props.expandedKeys === 'undefined' ? [] : props.expandedKeys, + }; + } + return null; + } + + onItemClick(key: KeyType) { + let expandedKeys = this.state.expandedKeys; + if (this.props.accordion) { + expandedKeys = String(expandedKeys[0]) === String(key) ? [] : [key]; + } else { + expandedKeys = [...expandedKeys]; + const stringKey = String(key); + const index = expandedKeys.findIndex(k => String(k) === stringKey); + const isExpanded = index > -1; + if (isExpanded) { + expandedKeys.splice(index, 1); + } else { + expandedKeys.push(key); + } + } + this.setExpandedKey(expandedKeys); + } + + genratePanelId(itemId: string | undefined, index: number) { + const { id: collapseId } = this.props; + let id; + if (itemId) { + // 优先用 item 自带的 id + id = itemId; + } else if (collapseId) { + // 其次用 collapseId 和 index 生成 id + id = `${collapseId}-panel-${index}`; + } + return id; + } + getProps(item: DataItem, index: number, key: KeyType) { + const expandedKeys = this.state.expandedKeys; + const { title } = item; + let disabled = this.props.disabled; + + if (!disabled) { + disabled = item.disabled; + } + + let isExpanded = false; + + if (this.props.accordion) { + isExpanded = String(expandedKeys[0]) === String(key); + } else { + isExpanded = expandedKeys.some(expandedKey => { + if ( + expandedKey === null || + expandedKey === undefined || + key === null || + key === undefined + ) { + return false; + } + + if (expandedKey === key || expandedKey.toString() === key.toString()) { + return true; + } + return false; + }); + } + + const id = this.genratePanelId(item.id, index); + return { + key, + title, + isExpanded, + disabled, + id, + onClick: disabled + ? null + : () => { + this.onItemClick(key); + if ('onClick' in item) { + item.onClick?.(key); + } + }, + }; + } + + getItemsByDataSource() { + const { props } = this; + const { dataSource } = props; + // 是否有 dataSource.item 传入过 key + const hasKeys = dataSource!.some(item => 'key' in item); + + return dataSource!.map((item, index) => { + // 传入过 key 就用 item.key 没传入则统一使用 index 为 key + const key = hasKeys ? item.key : `${index}`; + return ( + // @ts-expect-error FIXME 这里要确保 key 一定存在才能正常运行,hasKeys 的判断方式需要改进 + + {item.content} + + ); + }); + } + + getItemsByChildren() { + // 是否有 child 传入过 key + const allKeys = React.Children.map( + this.props.children, + (child: ReactElement) => child && child.key + ); + const hasKeys = Boolean(allKeys && allKeys.length); + + return React.Children.map(this.props.children, (child: ReactElement, index) => { + if ( + child && + typeof child.type === 'function' && + (child.type as typeof Panel).isNextPanel + ) { + // 传入过 key 就用 child.key 没传入则统一使用 index 为 key + const key = hasKeys ? child.key : `${index}`; + // @ts-expect-error FIXME 这里要确保 key 一定存在才能正常运行,hasKeys 的判断方式需要改进 + return React.cloneElement(child, this.getProps(child.props, index, key)); + } else { + return child; + } + }); + } + + setExpandedKey(expandedKeys: KeyType[]) { + if (!('expandedKeys' in this.props)) { + this.setState({ expandedKeys }); + } + this.props.onExpand?.(this.props.accordion ? expandedKeys[0] : expandedKeys); + } + + render() { + const { prefix, className, style, disabled, dataSource, id, rtl } = this.props; + const collapseClassName = classNames({ + [`${prefix}collapse`]: true, + [`${prefix}collapse-disabled`]: disabled, + [className!]: className, + }); + + const others = obj.pickOthers(Collapse.propTypes, this.props); + return ( + + ); + } +} + +export default polyfill(ConfigProvider.config(Collapse)); diff --git a/components/collapse/index.d.ts b/components/collapse/index.d.ts deleted file mode 100644 index dc4ccbcdf1..0000000000 --- a/components/collapse/index.d.ts +++ /dev/null @@ -1,96 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - title?: any; -} - -export interface PanelProps extends HTMLAttributesWeak, CommonProps { - /** - * 样式类名的品牌前缀 - */ - prefix?: string; - - /** - * 子组件接受行内样式 - */ - style?: React.CSSProperties; - - /** - * 是否禁止用户操作 - */ - disabled?: boolean; - - /** - * 标题 - */ - title?: React.ReactNode; - - /** - * 扩展class - */ - className?: string; -} - -export class Panel extends React.Component {} - -type data = { - title?: React.ReactNode; - content?: React.ReactNode; - disabled?: boolean; - key?: string; - [propName: string]: any; -}; - -export interface CollapseProps extends React.HTMLAttributes, CommonProps { - /** - * 样式前缀 - */ - prefix?: string; - - /** - * 组件接受行内样式 - */ - style?: React.CSSProperties; - - /** - * 使用数据模型构建 - */ - dataSource?: Array; - - /** - * 默认展开keys - */ - defaultExpandedKeys?: Array; - - /** - * 受控展开keys - */ - expandedKeys?: Array; - - /** - * 展开状态发升变化时候的回调 - */ - onExpand?: (expandedKeys: Array) => void; - - /** - * 所有禁用 - */ - disabled?: boolean; - - /** - * 扩展class - */ - className?: string; - - /** - * 手风琴模式,一次只能打开一个 - */ - accordion?: boolean; -} - -export default class Collapse extends React.Component { - static Panel: typeof Panel; -} diff --git a/components/collapse/index.jsx b/components/collapse/index.jsx deleted file mode 100644 index 3d9094b5ed..0000000000 --- a/components/collapse/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import Collapse from './collapse'; -import Panel from './panel'; - -Collapse.Panel = Panel; - -export default Collapse; diff --git a/components/collapse/index.tsx b/components/collapse/index.tsx new file mode 100644 index 0000000000..dadcb8653b --- /dev/null +++ b/components/collapse/index.tsx @@ -0,0 +1,9 @@ +import { assignSubComponent } from '../util/component'; +import Collapse from './collapse'; +import Panel from './panel'; +import type { CollapseProps, PanelProps } from './types'; + +const CollapseWithPanel = assignSubComponent(Collapse, { Panel }); + +export default CollapseWithPanel; +export type { CollapseProps, PanelProps }; diff --git a/components/collapse/main.scss b/components/collapse/main.scss index 4eb1e7ebdf..349023239e 100644 --- a/components/collapse/main.scss +++ b/components/collapse/main.scss @@ -10,6 +10,7 @@ border: $collapse-border-width solid $collapse-border-color; border-radius: $collapse-border-corner; + overflow: hidden; &:focus, & *:focus { outline: 0; diff --git a/components/collapse/mobile/index.jsx b/components/collapse/mobile/index.jsx deleted file mode 100644 index 340c246860..0000000000 --- a/components/collapse/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Collapse as MeetCollapse } from '@alifd/meet-react'; -import NextCollapse from '../index'; - -const Collapse = MeetCollapse ? MeetCollapse : NextCollapse; - -export default Collapse; diff --git a/components/collapse/mobile/index.tsx b/components/collapse/mobile/index.tsx new file mode 100644 index 0000000000..26d3ea3be1 --- /dev/null +++ b/components/collapse/mobile/index.tsx @@ -0,0 +1,7 @@ +// @ts-expect-error FIXME: Module '"@alifd/meet-react"' has no exported member 'Collapse'. +import { Collapse as MeetCollapse } from '@alifd/meet-react'; +import NextCollapse from '../index'; + +const Collapse = MeetCollapse ? MeetCollapse : NextCollapse; + +export default Collapse; diff --git a/components/collapse/panel.jsx b/components/collapse/panel.jsx deleted file mode 100644 index 44d98a6451..0000000000 --- a/components/collapse/panel.jsx +++ /dev/null @@ -1,96 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import classNames from 'classnames'; -import ConfigProvider from '../config-provider'; -import Icon from '../icon'; -import { func, KEYCODE } from '../util'; - -/** Collapse.Panel */ -class Panel extends React.Component { - static propTypes = { - /** - * 样式类名的品牌前缀 - */ - prefix: PropTypes.string, - /** - * 子组件接受行内样式 - */ - style: PropTypes.object, - children: PropTypes.any, - isExpanded: PropTypes.bool, - /** - * 是否禁止用户操作 - */ - disabled: PropTypes.bool, - /** - * 标题 - */ - title: PropTypes.node, - /** - * 扩展class - */ - className: PropTypes.string, - onClick: PropTypes.func, - id: PropTypes.string, - }; - - static defaultProps = { - prefix: 'next-', - isExpanded: false, - onClick: func.noop, - }; - - static isNextPanel = true; // - - onKeyDown = e => { - const { keyCode } = e; - if (keyCode === KEYCODE.SPACE) { - const { onClick } = this.props; - e.preventDefault(); - onClick && onClick(e); - } - }; - render() { - const { title, children, className, isExpanded, disabled, style, prefix, onClick, id, ...others } = this.props; - - const cls = classNames({ - [`${prefix}collapse-panel`]: true, - [`${prefix}collapse-panel-hidden`]: !isExpanded, - [`${prefix}collapse-panel-expanded`]: isExpanded, - [`${prefix}collapse-panel-disabled`]: disabled, - [className]: className, - }); - - const iconCls = classNames({ - [`${prefix}collapse-panel-icon`]: true, - [`${prefix}collapse-panel-icon-expanded`]: isExpanded, - }); - - // 为了无障碍 需要添加两个id - const headingId = id ? `${id}-heading` : undefined; - const regionId = id ? `${id}-region` : undefined; - return ( -
    -
    -
    -
    - {children} -
    -
    - ); - } -} - -export default ConfigProvider.config(Panel); diff --git a/components/collapse/panel.tsx b/components/collapse/panel.tsx new file mode 100644 index 0000000000..b7dbbe619a --- /dev/null +++ b/components/collapse/panel.tsx @@ -0,0 +1,108 @@ +import React, { type KeyboardEvent } from 'react'; +import PropTypes from 'prop-types'; +import classNames from 'classnames'; +import ConfigProvider from '../config-provider'; +import Icon from '../icon'; +import { func, KEYCODE } from '../util'; +import { type PanelProps } from './types'; + +/** Collapse.Panel */ +class Panel extends React.Component { + static propTypes = { + /** + * 样式类名的品牌前缀 + */ + prefix: PropTypes.string, + /** + * 子组件接受行内样式 + */ + style: PropTypes.object, + children: PropTypes.any, + isExpanded: PropTypes.bool, + /** + * 是否禁止用户操作 + */ + disabled: PropTypes.bool, + /** + * 标题 + */ + title: PropTypes.node, + /** + * 扩展 class + */ + className: PropTypes.string, + onClick: PropTypes.func, + id: PropTypes.string, + }; + + static defaultProps = { + prefix: 'next-', + isExpanded: false, + onClick: func.noop, + }; + + static isNextPanel = true; + + onKeyDown = (e: KeyboardEvent) => { + const { keyCode } = e; + if (keyCode === KEYCODE.SPACE) { + const { onClick } = this.props; + e.preventDefault(); + onClick && onClick(e); + } + }; + render() { + const { + title, + children, + className, + isExpanded, + disabled, + style, + prefix, + onClick, + id, + ...others + } = this.props; + + const cls = classNames({ + [`${prefix}collapse-panel`]: true, + [`${prefix}collapse-panel-hidden`]: !isExpanded, + [`${prefix}collapse-panel-expanded`]: isExpanded, + [`${prefix}collapse-panel-disabled`]: disabled, + [className!]: className, + }); + + const iconCls = classNames({ + [`${prefix}collapse-panel-icon`]: true, + [`${prefix}collapse-panel-icon-expanded`]: isExpanded, + }); + + // 为了无障碍 需要添加两个 id + const headingId = id ? `${id}-heading` : undefined; + const regionId = id ? `${id}-region` : undefined; + return ( +
    +
    +
    +
    + {children} +
    +
    + ); + } +} + +export default ConfigProvider.config(Panel); diff --git a/components/collapse/style.js b/components/collapse/style.ts similarity index 100% rename from components/collapse/style.js rename to components/collapse/style.ts diff --git a/components/collapse/types.ts b/components/collapse/types.ts new file mode 100644 index 0000000000..442b99f550 --- /dev/null +++ b/components/collapse/types.ts @@ -0,0 +1,96 @@ +import React from 'react'; +import { CommonProps } from '../util'; + +type HTMLAttributesWeak = Omit, 'title' | 'onClick'>; + +/** + * @api Collapse.Panel + */ +export interface PanelProps extends HTMLAttributesWeak, CommonProps { + /** + * 是否禁止用户操作 + * @en Whether to disable user actions + */ + disabled?: boolean; + + /** + * 标题 + * @en Title + */ + title?: React.ReactNode; + /** + * 是否展开 + * @en Whether to expand + * @defaultValue false + */ + + isExpanded?: boolean; + + /** + * 点击回调函数 + * @en Click callback function + */ + onClick?: + | ((e: React.MouseEvent | React.KeyboardEvent) => void) + | null; +} + +/** + * @api + */ +export type KeyType = string | number; + +/** + * @api + */ +export type DataItem = { + id?: string; + title?: React.ReactNode; + content?: React.ReactNode; + disabled?: boolean; + key?: KeyType; + onClick?: (key: KeyType) => void; + [propName: string]: unknown; +}; + +/** + * @api Collapse + */ +export interface CollapseProps extends React.HTMLAttributes, CommonProps { + /** + * 使用数据模型构建 + * @en Use data model to build + */ + dataSource?: Array; + + /** + * 默认展开 keys + * @en Default expanded keys + */ + defaultExpandedKeys?: KeyType[]; + + /** + * 受控展开 keys + * @en Controlled expanded keys + */ + expandedKeys?: KeyType[]; + + /** + * 展开状态发升变化时候的回调 + * @en Callback when the expanded state changes + */ + onExpand?: (expandedKeys: KeyType | KeyType[]) => void; + + /** + * 所有禁用 + * @en All disabled + */ + disabled?: boolean; + + /** + * 手风琴模式,一次只能打开一个 + * @en Accordion mode, only one can be opened at a time + * @defaultValue false + */ + accordion?: boolean; +} diff --git a/components/config-provider/__docs__/index.en-us.md b/components/config-provider/__docs__/index.en-us.md index 141468a825..3b4d67dcc7 100644 --- a/components/config-provider/__docs__/index.en-us.md +++ b/components/config-provider/__docs__/index.en-us.md @@ -11,11 +11,11 @@ ### When To Use -- Modify the component class name prefix, the default prefix of the Next component class name is 'next-', such as 'next-btn', you may want to change this default prefix in the following two cases: - - Custom component brands such as 'my-btn', 'my-select' - - Two themes in a page at the same time, preventing the same class name from overwriting each other -- Support multiple languages -- Enable Pure Render mode to improve performance, and note that it may have side effects +- Modify the component class name prefix, the default prefix of the Next component class name is 'next-', such as 'next-btn', you may want to change this default prefix in the following two cases: + - Custom component brands such as 'my-btn', 'my-select' + - Two themes in a page at the same time, preventing the same class name from overwriting each other +- Support multiple languages +- Enable Pure Render mode to improve performance, and note that it may have side effects ### Basic Usage @@ -36,7 +36,6 @@ import enUS from '@alifd/next/lib/locale/en-us'; // const { ConfigProvider, DatePicker, locales } = window.Next; // const enUS = locales['en-us']; - class App extends React.Component { render() { return ( @@ -94,8 +93,8 @@ The passed locale object has the following format: entry.scss ```scss - $css-prefix: "my-"; - @import "~@alifd/theme-xxx/index.scss"; + $css-prefix: 'my-'; + @import '~@alifd/theme-xxx/index.scss'; ``` #### Enable Pure Render @@ -126,13 +125,13 @@ class Component extends React.Component { static propTypes = { prefix: PropTypes.string, locale: PropTypes.object, - pure: PropTypes.bool + pure: PropTypes.bool, }; static defaultProps = { prefix: 'next-', locale: locale, - pure: false + pure: false, }; render() { @@ -148,14 +147,30 @@ export default config(Component); ### ConfigProvider -| Param | Description | Type | Default Value | -| -------- | ----------------------------------- | ------------ | --- | -| errorBoundary | turn errorBoundary on or not
    If you pass object, properties:

    fallbackUI `Function(error?: {}, errorInfo?: {}) => Element`
    afterCatch `Function(error?: {}, errorInfo?: {})` after being catched, e.g. send data to server for data statistics | Boolean/Object | false | -| pure | whether enable the Pure Render mode, it will improve performance, but it will also have side effects | Boolean | - | -| device | Responsive of device
    Options:
    `desktop`, `tablet`, `phone` | - | -| warning | whether to display the warning prompt for component properties being deprecated in development mode | Boolean | true | -| children | component tree | ReactElement | - | -| popupContainer | shell container node | String/Function | - | +| Param | Description | Type | Default Value | Required | +| ------------------ | --------------------------------------------------------------------------------------------------- | ----------------------- | ------------- | -------- | +| prefix | Prefix of component className | string | - | | +| pure | Enable the Pure Render mode, it will improve performance, but it will also have side effects | boolean | - | | +| device | Responsive of device | DeviceType | - | | +| rtl | Enable right to left mode | boolean | - | | +| errorBoundary | Turn errorBoundary on or not | ErrorBoundaryType | false | | +| warning | Whether to display the warning prompt for component properties being deprecated in development mode | boolean | true | | +| locale | Locale object for components | Partial\ | - | | +| popupContainer | Shell container node | PopupContainerType | - | | +| children | Children nodes | React.ReactNode | - | | +| defaultPropsConfig | Set default props of components in batches | Record\ | - | | + +### DeviceType + +```typescript +export type DeviceType = 'tablet' | 'desktop' | 'phone'; +``` + +### PopupContainerType + +```typescript +export type PopupContainerType = string | HTMLElement | ((target: HTMLElement) => HTMLElement); +``` @@ -197,7 +212,7 @@ Config locales, together with method `ConfigProvider.setLanguage` to specify the ```js ConfigProvider.initLocales({ 'zh-cn': {}, - 'en-us': {} + 'en-us': {}, }); ``` @@ -217,7 +232,7 @@ Set language package directly. // The effect is the same as using ConfigProvider.initLocales and ConfigProvider.setLanguage ConfigProvider.setLocale({ DatePicker: {}, - Dialog: {} + Dialog: {}, }); ``` @@ -247,7 +262,7 @@ Return the direction. ### Reduce moment size built with webpack -Next 1.x will use moment as its peerDependencies instead of dependencies, so the user needs to import the moment's cdn file moment-with-locales.js or the local moment module into his own application. For the latter, due to the moment has such code: `require('./locale/' + name)` to import locale files, if built with webpack, it will be packaged into all [locale files](https://github.com/moment/moment/tree/develop/locale) to increase the size of files. There are two main solutions in the current community: +Next 1.x will use moment as its peerDependencies instead of dependencies, so the user needs to import the moment's cdn file moment-with-locales.js or the local moment module into his own application. For the latter, due to the moment has such code: `require('./locale/' + name)` to import locale files, if built with webpack, it will be packaged into all [locale files](https://github.com/moment/moment/tree/develop/locale) to increase the size of files. There are two main solutions in the current community: ```js const webpack = require('webpack'); @@ -255,11 +270,11 @@ const webpack = require('webpack'); module.exports = { // ... plugins: [ - // Package the specified language files - new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn|ja/) - // Only package the language files referenced, and should add `import 'moment/locale/zh-cn';` in the application - // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) - ] + // Package the specified language files + new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn|ja/), + // Only package the language files referenced, and should add `import 'moment/locale/zh-cn';` in the application + // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) + ], }; ``` @@ -269,8 +284,8 @@ ConfigProvider use a Component's displayName or name to get its locale. However, ```js class CustomComponent extends React.Component { - static displayName = 'CustomComponent'; - // ... + static displayName = 'CustomComponent'; + // ... } ``` @@ -279,6 +294,7 @@ Or use `babel-plugin-transform-react-es6-displayname` to add displayName during ### Get the internal component's reference of the HOC Due to the limit of the HOC itself, we can't get the reference of an internal component and call some of its internal methods as below: + ```js class App extends React.Component { componentDidMount() { diff --git a/components/config-provider/__docs__/index.md b/components/config-provider/__docs__/index.md index c9da4e1b19..01d2bc93cc 100644 --- a/components/config-provider/__docs__/index.md +++ b/components/config-provider/__docs__/index.md @@ -60,7 +60,7 @@ props 方式的 locale > 最近 ConfigProvider 的 locale > 更远父级 ConfigP ```jsx -
    ); @@ -113,7 +114,6 @@ import enUS from '@alifd/next/lib/locale/en-us'; // const { ConfigProvider, DatePicker, locales } = window.Next; // const enUS = locales['en-us']; - class App extends React.Component { render() { return ( @@ -171,8 +171,8 @@ class App extends React.Component { entry.scss ```scss - $css-prefix: "my-"; - @import "~@alifd/theme-xxx/index.scss"; + $css-prefix: 'my-'; + @import '~@alifd/theme-xxx/index.scss'; ``` ### 开启 Pure Render @@ -203,13 +203,13 @@ class Component extends React.Component { static propTypes = { prefix: PropTypes.string, locale: PropTypes.object, - pure: PropTypes.bool + pure: PropTypes.bool, }; static defaultProps = { prefix: 'next-', locale: locale, - pure: false + pure: false, }; render() { @@ -225,16 +225,30 @@ export default config(Component); ### ConfigProvider -| 参数 | 说明 | 类型 | 默认值 | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ----- | -| defaultPropsConfig | 组件 API 的默认配置 | Object | - | -| errorBoundary | 是否开启错误捕捉 errorBoundary
    如需自定义参数,请传入对象 对象接受参数列表如下:

    fallbackUI `Function(error?: {}, errorInfo?: {}) => Element` 捕获错误后的展示
    afterCatch `Function(error?: {}, errorInfo?: {})` 捕获错误后的行为, 比如埋点上传 | Boolean/Object | false | -| pure | 是否开启 Pure Render 模式,会提高性能,但是也会带来副作用 | Boolean | - | -| warning | 是否在开发模式下显示组件属性被废弃的 warning 提示 | Boolean | true | -| rtl | 是否开启 rtl 模式 | Boolean | - | -| device | 设备类型,针对不同的设备类型组件做出对应的响应式变化

    **可选值**:
    'tablet', 'desktop', 'phone' | Enum | - | -| children | 组件树 | any | - | -| popupContainer | 指定浮层渲染的父节点, 可以为节点id的字符串,也可以返回节点的函数 | any | - | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| ------------------ | ------------------------------------------------------------------ | ----------------------- | ------ | -------- | +| prefix | 样式类名的品牌前缀 | string | - | | +| pure | 是否开启 Pure Render 模式,会提高性能,但是也会带来副作用 | boolean | - | | +| device | 设备类型,针对不同的设备类型组件做出对应的响应式变化 | DeviceType | - | | +| rtl | 是否开启 rtl 模式 | boolean | - | | +| errorBoundary | 是否开启错误捕捉 | ErrorBoundaryType | false | | +| warning | 是否在开发模式下显示组件属性被废弃的 warning 提示 | boolean | true | | +| locale | 各组件的国际化文案对象,属性为组件的 displayName | Partial\ | - | | +| popupContainer | 指定浮层渲染的父节点,可以为节点 id 的字符串,也可以返回节点的函数 | PopupContainerType | - | | +| children | 组件树 | React.ReactNode | - | | +| defaultPropsConfig | 各组件 API 的默认配置 | Record\ | - | | + +### DeviceType + +```typescript +export type DeviceType = 'tablet' | 'desktop' | 'phone'; +``` + +### PopupContainerType + +```typescript +export type PopupContainerType = string | HTMLElement | ((target: HTMLElement) => HTMLElement); +``` @@ -276,7 +290,7 @@ Component.prototype.shouldComponentUpdate = function shouldComponentUpdate(nextP ```jsx ConfigProvider.initLocales({ 'zh-cn': {}, - 'en-us': {} + 'en-us': {}, }); ``` @@ -296,7 +310,7 @@ ConfigProvider.setLanguage('zh-cn'); // 相当于 同时用ConfigProvider.initLocales 和 ConfigProvider.setLanguage ConfigProvider.setLocale({ DatePicker: {}, - Dialog: {} + Dialog: {}, }); ``` @@ -334,11 +348,11 @@ const webpack = require('webpack'); module.exports = { // ... plugins: [ - // 打包指定需要的语言文件 - new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn|ja/) - // 只打包有过引用的语言文件,应用中需要添加如:`import 'moment/locale/zh-cn';` - // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) - ] + // 打包指定需要的语言文件 + new webpack.ContextReplacementPlugin(/moment[\/\\]locale$/, /zh-cn|ja/), + // 只打包有过引用的语言文件,应用中需要添加如:`import 'moment/locale/zh-cn';` + // new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/) + ], }; ``` @@ -348,8 +362,8 @@ ConfigProvider 获取组件对应的多语言文案,是通过组件的 display ```jsx class CustomComponent extends React.Component { - static displayName = 'CustomComponent'; - // ... + static displayName = 'CustomComponent'; + // ... } ``` diff --git a/components/config-provider/__tests__/index-spec.tsx b/components/config-provider/__tests__/index-spec.tsx index ac54ed49ca..d9a6c8c214 100644 --- a/components/config-provider/__tests__/index-spec.tsx +++ b/components/config-provider/__tests__/index-spec.tsx @@ -1,4 +1,12 @@ -import React, { forwardRef, Component, FC, EventHandler, MouseEvent } from 'react'; +import React, { + forwardRef, + Component, + FC, + EventHandler, + MouseEvent, + useImperativeHandle, + createRef, +} from 'react'; import moment from 'moment'; import 'moment/locale/zh-cn'; import Select from '../../select'; @@ -9,6 +17,7 @@ import ConfigProvider from '../index'; import { ComponentCommonProps } from '../types'; import { render, shallow } from '../../util/__tests__'; import { ConsumerState } from '../consumer'; +import { CommonProps } from '../../util'; const { config, getContextProps, ErrorBoundary } = ConfigProvider; @@ -74,7 +83,12 @@ class ClickMe extends Component void }> { + +interface ToastProps extends ComponentCommonProps { + afterClose: () => void; +} + +class Toast extends Component { static defaultProps = { locale: locales['zh-cn'].Toast, afterClose: () => {}, @@ -85,7 +99,7 @@ class Toast extends Component void } return wrapper; }; - constructor(props: any) { + constructor(props: ToastProps) { super(props); this.state = { @@ -99,7 +113,7 @@ class Toast extends Component void } this.setState({ visible: false, }); - this.props.afterClose!(); + this.props.afterClose(); } render() { @@ -112,8 +126,12 @@ class Toast extends Component void } } const NewClickMe = config(ClickMe); -const NewToast = config(Toast) as unknown as typeof Toast; -class Demo extends Component { +const NewToast = config(Toast); + +type Language = 'zh-cn' | 'en-us'; +class Demo extends Component { + wrapper: ReturnType | null = null; + constructor(props: unknown) { super(props); @@ -129,8 +147,6 @@ class Demo extends Component { this.wrapper && this.wrapper.unmount(); } - wrapper: ReturnType | null = null; - handleClick() { if (this.wrapper) { this.wrapper.unmount(); @@ -139,9 +155,9 @@ class Demo extends Component { this.wrapper = NewToast.create(); } - handleChangeLanguage(e: any) { + handleChangeLanguage(e: React.ChangeEvent) { this.setState({ - language: e.target.value, + language: e.target.value as Language, }); } @@ -165,40 +181,6 @@ class Demo extends Component { } describe('ConfigProvider', () => { - it('should support function component', () => { - const FC: FC<{ title: string }> = ({ title }) => { - return
    {title}
    ; - }; - const ConfigFC = config(FC); - cy.mount( - - - - ); - cy.get('[data-cy="fc"]').should('have.text', 'ssss'); - }); - - it('should support forwardRef component', () => { - const ForwardFC = forwardRef(({ title }, ref) => { - return
    {title}
    ; - }); - const ConfigForwardFC = config(ForwardFC); - cy.mount( - - - - ); - cy.get('[data-cy="ffc"]').should('have.text', 'ssss'); - }); - - it('should use default prop by default', () => { - cy.mount(); - cy.get('[data-cy="output"]') - .should('have.attr', 'data-cy-prefix', 'next-') - .should('have.attr', 'data-cy-pure', 'false') - .should('have.attr', 'data-cy-locale', '你好'); - }); - it('should use context prop if wrapped by ConfigProvider', () => { cy.mount( @@ -223,63 +205,6 @@ describe('ConfigProvider', () => { .should('have.attr', 'data-cy-locale', 'my'); }); - it('should expose getInstance method', () => { - const ref: { current: any } = { current: null }; - cy.mount().then(() => { - expect(typeof ref.current?.getInstance?.().internalMethod).to.equal('function'); - }); - }); - - it('should not pure render by default', () => { - const obj = { text: '0' }; - class Pure extends Component<{ obj: { text: string } }> { - render() { - return
    {this.props.obj.text}
    ; - } - } - - const ConfigPure = config(Pure); - cy.mount().then(xx => { - obj.text = '1'; - xx.rerender(); - cy.get('[data-cy="pure"]').should('have.text', '1'); - }); - }); - - it('should pure render if set pure to true', () => { - const obj = { text: '0' }; - class Pure extends Component<{ obj: { text: string } }> { - render() { - return
    {this.props.obj.text}
    ; - } - } - - const ConfigPure = config(Pure); - cy.mount().then(xx => { - obj.text = '1'; - xx.rerender(); - cy.get('[data-cy="pure"]').should('have.text', '0'); - }); - }); - - it('should change context of component which is off the component tree', () => { - cy.mount(); - cy.get('.click-me').click(); - cy.then(() => { - let toast: HTMLButtonElement | null = document.querySelector('.toast button'); - expect(toast?.innerHTML.trim()).to.equal('关闭'); - toast!.click(); - - cy.get('select').invoke('val', 'en-us').trigger('change'); - - cy.get('.click-me').click(); - cy.then(() => { - toast = document.querySelector('.toast button'); - expect(toast?.innerHTML.trim()).to.equal('close'); - }); - }); - }); - it('should change moment locale', () => { cy.mount( @@ -289,19 +214,6 @@ describe('ConfigProvider', () => { expect(moment.locale()).to.equal('zh-cn'); }); - it('should support alias displayName', () => { - const FC: FC<{ locale?: { text?: string } }> = ({ locale }) => { - return
    {locale?.text}
    ; - }; - const ConfigFC = config(FC, { componentName: 'B' }); - cy.mount( - - - - ); - cy.get('[data-cy="alias"]').should('have.text', '2'); - }); - it('should support setLanguage', () => { ConfigProvider.initLocales({ 'zh-cn': zhCN, @@ -324,13 +236,492 @@ describe('ConfigProvider', () => { ConfigProvider.setLocale({ Select: { selectPlaceholder: '哈哈', - } as any, + }, }); cy.mount( - ); - - const datePanel = ( - - ); - - let panelFooter = footerRender(); - - let timeInput = null; - let timePanel = null; - - if (showTime) { - const timeInputValue = inputing === 'time' ? timeInputStr : (value && value.format(timeFormat)) || ''; - triggerInputValue = (value && value.format(dateTimeFormat)) || ''; - - const timePanelProps = typeof showTime === 'object' ? showTime : {}; - - const showSecond = timeFormat.indexOf('s') > -1; - const showMinute = timeFormat.indexOf('m') > -1; - - const panelTimeInputCls = classnames({ - [`${prefix}date-picker-panel-input`]: true, - [`${prefix}focus`]: panel === PANEL.TIME, - }); - - timeInput = ( - - ); - - timePanel = ( - - ); - - panelFooter = panelFooter || ( - - ); - } - - const panelBody = { - [PANEL.DATE]: datePanel, - [PANEL.TIME]: timePanel, - }[panel]; - - const allowClear = value && hasClear; - const trigger = ( -
    - } - hasClear={allowClear} - className={triggerInputCls} - /> -
    - ); - const PopupComponent = popupComponent ? popupComponent : Popup; - - return ( -
    - - {popupContent ? ( - popupContent - ) : ( -
    -
    - {dateInput} - {timeInput} -
    - {panelBody} - {panelFooter} -
    - )} -
    -
    - ); - } -} - -export default polyfill(DatePicker); diff --git a/components/date-picker/date-picker.tsx b/components/date-picker/date-picker.tsx new file mode 100644 index 0000000000..07816b75d8 --- /dev/null +++ b/components/date-picker/date-picker.tsx @@ -0,0 +1,634 @@ +import React, { Component, type HTMLAttributes, type KeyboardEvent, type UIEvent } from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import ConfigProvider from '../config-provider'; +import Overlay from '../overlay'; +import Input, { type InputProps } from '../input'; +import Icon from '../icon'; +import Calendar from '../calendar'; +import TimePickerPanel from '../time-picker/panel'; +import nextLocale from '../locale/zh-cn'; +import { type ClassPropsWithDefault, func, obj } from '../util'; +import { + PANEL, + resetValueTime, + checkDateValue, + formatDateValue, + getDateTimeFormat, + onDateKeydown, + onTimeKeydown, +} from './util'; +import PanelFooter from './module/panel-footer'; +import type { DatePickerProps, DatePickerState } from './types'; +import { type TimePickerProps } from '../time-picker'; + +const { Popup } = Overlay; + +type InnerDatePickerProps = ClassPropsWithDefault; + +/** + * DatePicker + */ +class DatePicker extends Component { + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + label: PropTypes.node, + state: PropTypes.oneOf(['success', 'loading', 'error']), + placeholder: PropTypes.string, + defaultVisibleMonth: PropTypes.func, + onVisibleMonthChange: PropTypes.func, + value: checkDateValue, + defaultValue: checkDateValue, + format: PropTypes.string, + showTime: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + resetTime: PropTypes.bool, + disabledDate: PropTypes.func, + footerRender: PropTypes.func, + onChange: PropTypes.func, + onOk: PropTypes.func, + size: PropTypes.oneOf(['small', 'medium', 'large']), + disabled: PropTypes.bool, + hasClear: PropTypes.bool, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + popupTriggerType: PropTypes.oneOf(['click', 'hover']), + popupAlign: PropTypes.string, + popupContainer: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + inputProps: PropTypes.object, + dateCellRender: PropTypes.func, + monthCellRender: PropTypes.func, + yearCellRender: PropTypes.func, + dateInputAriaLabel: PropTypes.string, + timeInputAriaLabel: PropTypes.string, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + locale: PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + popupComponent: PropTypes.elementType, + popupContent: PropTypes.node, + disableChangeMode: PropTypes.bool, + yearRange: PropTypes.arrayOf(PropTypes.number), + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + format: 'YYYY-MM-DD', + size: 'medium', + showTime: false, + resetTime: false, + disabledDate: () => false, + footerRender: () => null, + hasClear: true, + popupTriggerType: 'click', + popupAlign: 'tl tl', + locale: nextLocale.DatePicker, + defaultVisible: false, + onChange: func.noop, + onVisibleChange: func.noop, + onOk: func.noop, + }; + + static displayName = 'DatePicker'; + + readonly props: InnerDatePickerProps; + + constructor(props: DatePickerProps) { + super(props); + const { format, timeFormat, dateTimeFormat } = getDateTimeFormat( + props.format, + props.showTime + ); + + this.state = { + value: formatDateValue(props.defaultValue, dateTimeFormat), + dateInputStr: '', + timeInputStr: '', + inputing: false, // 当前是否处于输入状态 + visible: props.defaultVisible!, + inputAsString: typeof props.defaultValue === 'string', + panel: PANEL.DATE, + format: format!, + timeFormat, + dateTimeFormat: dateTimeFormat!, + }; + } + + static getDerivedStateFromProps(props: InnerDatePickerProps) { + const formatStates = getDateTimeFormat(props.format, props.showTime); + const states: Partial = {}; + + if ('value' in props) { + states.value = formatDateValue(props.value, formatStates.dateTimeFormat); + if (typeof props.value === 'string') { + states.inputAsString = true; + } + if (moment.isMoment(props.value)) { + states.inputAsString = false; + } + } + + if ('visible' in props) { + states.visible = props.visible; + } + + return { + ...states, + ...formatStates, + }; + } + + onValueChange = (newValue: Moment | null, handler: 'onOk' | 'onChange' = 'onChange') => { + const ret = + this.state.inputAsString && newValue + ? newValue.format(this.state.dateTimeFormat) + : newValue; + this.props[handler](ret); + }; + + onSelectCalendarPanel = (value: Moment) => { + const { showTime, resetTime } = this.props; + + const prevValue = this.state.value; + let newValue = value; + if (showTime) { + if (!prevValue) { + // 第一次选择日期值时,如果设置了默认时间,则使用该默认时间 + if ((showTime as TimePickerProps).defaultValue) { + const defaultTimeValue = formatDateValue( + (showTime as TimePickerProps).defaultValue, + this.state.timeFormat + ); + newValue = resetValueTime(value, defaultTimeValue); + } + } else if (!resetTime) { + // 非第一选择日期,如果开启了 resetTime 属性,则记住之前选择的时间值 + newValue = resetValueTime(value, prevValue); + } + } + + this.handleChange(newValue, prevValue, { inputing: false }); + + if (!showTime) { + this.onVisibleChange(false, 'calendarSelect'); + } + }; + + onSelectTimePanel = (value: Moment) => { + this.handleChange(value, this.state.value, { inputing: false }); + }; + + clearValue = () => { + this.setState({ + dateInputStr: '', + timeInputStr: '', + }); + + this.handleChange(null, this.state.value, { inputing: false }); + }; + + onDateInputChange = (inputStr: string | null | undefined, e: UIEvent, eventType?: string) => { + if (eventType === 'clear' || !inputStr) { + e.stopPropagation(); + this.clearValue(); + } else { + this.setState({ + dateInputStr: inputStr, + inputing: 'date', + }); + } + }; + + onTimeInputChange = (inputStr: string) => { + this.setState({ + timeInputStr: inputStr, + inputing: 'time', + }); + }; + + onDateInputBlur = () => { + const { dateInputStr, value, format } = this.state; + const { resetTime } = this.props; + + if (dateInputStr) { + const { disabledDate } = this.props; + let parsed = moment(dateInputStr, format, true); + + this.setState({ + dateInputStr: '', + inputing: false, + }); + if (parsed.isValid() && !disabledDate(parsed, 'date')) { + parsed = resetTime ? parsed : resetValueTime(parsed, value); + this.handleChange(parsed, value); + } + } + }; + + onTimeInputBlur = () => { + const { value, timeInputStr, timeFormat } = this.state; + if (timeInputStr) { + const parsed = moment(timeInputStr, timeFormat, true); + + this.setState({ + timeInputStr: '', + inputing: false, + }); + + if (parsed.isValid()) { + const hour = parsed.hour(); + const minute = parsed.minute(); + const second = parsed.second(); + // @ts-expect-error 没有考虑 value 为 null 的情况 + const newValue = value.clone().hour(hour).minute(minute).second(second); + + this.handleChange(newValue, this.state.value); + } + } + }; + + onKeyDown = (e: KeyboardEvent) => { + const { format } = this.props; + const { dateInputStr, value } = this.state; + const dateStr = onDateKeydown(e, { format, dateInputStr, value }, 'day'); + if (!dateStr) return; + this.onDateInputChange(dateStr, e); + }; + + onTimeKeyDown = (e: KeyboardEvent) => { + const { showTime } = this.props; + const { timeInputStr, timeFormat, value } = this.state; + const { + disabledMinutes, + disabledSeconds, + hourStep = 1, + minuteStep = 1, + secondStep = 1, + } = typeof showTime === 'object' ? showTime : ({} as TimePickerProps); + let unit: 'second' | 'minute' | 'hour' = 'second'; + + if (disabledSeconds) { + unit = disabledMinutes ? 'hour' : 'minute'; + } + + const timeStr = onTimeKeydown( + e, + { + format: timeFormat, + timeInputStr, + value, + steps: { + hour: hourStep, + minute: minuteStep, + second: secondStep, + }, + }, + unit + ); + + if (!timeStr) return; + + this.onTimeInputChange(timeStr); + }; + + handleChange = (newValue: Moment | null, prevValue: Moment | null, others = {}) => { + if (!('value' in this.props)) { + this.setState({ + value: newValue, + ...others, + }); + } else { + this.setState({ + ...others, + }); + } + + const newValueOf = newValue ? newValue.valueOf() : null; + const preValueOf = prevValue ? prevValue.valueOf() : null; + + if (newValueOf !== preValueOf) { + this.onValueChange(newValue); + } + }; + + onFoucsDateInput = () => { + if (this.state.panel !== PANEL.DATE) { + this.setState({ + panel: PANEL.DATE, + }); + } + }; + + onFoucsTimeInput = () => { + if (this.state.panel !== PANEL.TIME) { + this.setState({ + panel: PANEL.TIME, + }); + } + }; + + onVisibleChange = (visible: boolean, type: string) => { + if (!('visible' in this.props)) { + this.setState({ + visible, + }); + } + this.props.onVisibleChange(visible, type); + }; + + changePanel = (panel: DatePickerState['panel']) => { + this.setState({ + panel, + }); + }; + + onOk = (value?: Moment | null) => { + this.onVisibleChange(false, 'okBtnClick'); + this.onValueChange(value || this.state.value, 'onOk'); + }; + + renderPreview(others: HTMLAttributes) { + const { prefix, className, renderPreview } = this.props; + const { value, dateTimeFormat } = this.state; + const previewCls = classnames(className, `${prefix}form-preview`); + + const label = value ? value.format(dateTimeFormat) : ''; + + if (typeof renderPreview === 'function') { + return ( +
    + {renderPreview(value, this.props)} +
    + ); + } + + return ( +

    + {label} +

    + ); + } + + render() { + const { + prefix, + rtl, + locale, + label, + state, + defaultVisibleMonth, + onVisibleMonthChange, + showTime, + disabledDate, + footerRender, + placeholder, + size, + disabled, + hasClear, + popupTriggerType, + popupAlign, + popupContainer, + popupStyle, + popupClassName, + popupProps, + popupComponent, + popupContent, + followTrigger, + className, + inputProps, + dateCellRender, + monthCellRender, + yearCellRender, + dateInputAriaLabel, + timeInputAriaLabel, + isPreview, + disableChangeMode, + yearRange, + ...others + } = this.props; + + const { + visible, + value, + dateInputStr, + timeInputStr, + panel, + inputing, + format, + timeFormat, + dateTimeFormat, + } = this.state; + + const datePickerCls = classnames( + { + [`${prefix}date-picker`]: true, + }, + className + ); + + const triggerInputCls = classnames({ + [`${prefix}date-picker-input`]: true, + [`${prefix}error`]: false, + }); + + const panelBodyClassName = classnames({ + [`${prefix}date-picker-body`]: true, + [`${prefix}date-picker-body-show-time`]: showTime, + }); + + const panelDateInputCls = classnames({ + [`${prefix}date-picker-panel-input`]: true, + [`${prefix}focus`]: panel === PANEL.DATE, + }); + + if (rtl) { + others.dir = 'rtl'; + } + + if (isPreview) { + // @ts-expect-error 应该使用 propTypes + return this.renderPreview(obj.pickOthers(others, DatePicker.PropTypes)); + } + + const sharedInputProps = { + ...inputProps, + size, + disabled, + onChange: this.onDateInputChange as InputProps['onChange'], + onBlur: this.onDateInputBlur, + onPressEnter: this.onDateInputBlur, + onKeyDown: this.onKeyDown, + }; + + const dateInputValue = + inputing === 'date' ? dateInputStr : (value && value.format(format)) || ''; + let triggerInputValue = dateInputValue; + + const dateInput = ( + + ); + + const datePanel = ( + + ); + + let panelFooter = footerRender(); + + let timeInput = null; + let timePanel = null; + + if (showTime) { + const timeInputValue = + inputing === 'time' ? timeInputStr : (value && value.format(timeFormat)) || ''; + triggerInputValue = (value && value.format(dateTimeFormat)) || ''; + + const timePanelProps = typeof showTime === 'object' ? showTime : {}; + + const showSecond = timeFormat.indexOf('s') > -1; + const showMinute = timeFormat.indexOf('m') > -1; + + const panelTimeInputCls = classnames({ + [`${prefix}date-picker-panel-input`]: true, + [`${prefix}focus`]: panel === PANEL.TIME, + }); + + timeInput = ( + + ); + + timePanel = ( + + ); + + panelFooter = panelFooter || ( + + ); + } + + const panelBody = { + [PANEL.DATE]: datePanel, + [PANEL.TIME]: timePanel, + }[panel]; + + const allowClear = value && hasClear; + const trigger = ( +
    + + } + // @ts-expect-error allowClear 应该先做 boolean 化处理 + hasClear={allowClear} + className={triggerInputCls} + /> +
    + ); + const PopupComponent = popupComponent ? popupComponent : Popup; + + return ( +
    + + {popupContent ? ( + popupContent + ) : ( +
    +
    + {dateInput} + {timeInput} +
    + {panelBody} + {panelFooter} +
    + )} +
    +
    + ); + } +} + +export default polyfill(DatePicker); diff --git a/components/date-picker/index.d.ts b/components/date-picker/index.d.ts deleted file mode 100644 index 8fed0e7f41..0000000000 --- a/components/date-picker/index.d.ts +++ /dev/null @@ -1,640 +0,0 @@ -/// -import { Moment } from 'moment'; -import React from 'react'; -import { CommonProps } from '../util'; -import { PopupProps } from '../overlay'; -import { InputProps } from '../input'; - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -export interface MonthPickerProps extends HTMLAttributesWeak, CommonProps { - name?: string; - /** - * 输入框内置标签 - */ - label?: React.ReactNode; - - /** - * 输入框状态 - */ - state?: 'success' | 'loading' | 'error'; - - /** - * 输入提示 - */ - placeholder?: string; - - /** - * 默认展现的年 - */ - defaultVisibleYear?: () => void; - - /** - * 日期值(受控)moment 对象 - */ - value?: any; - - /** - * 初始日期值,moment 对象 - */ - defaultValue?: any; - - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format?: string; - - /** - * 禁用日期函数 - */ - disabledDate?: (date: Moment, view: string) => boolean; - - /** - * 自定义面板页脚 - */ - footerRender?: () => React.ReactNode; - - /** - * 日期值改变时的回调 - */ - onChange?: (value: any | string) => void; - - /** - * 输入框尺寸 - */ - size?: 'small' | 'medium' | 'large'; - - /** - * 是否禁用 - */ - disabled?: boolean; - - /** - * 是否显示清空按钮 - */ - hasClear?: boolean; - - /** - * 弹层显示状态 - */ - visible?: boolean; - - /** - * 弹层默认是否显示 - */ - defaultVisible?: boolean; - - /** - * 弹层展示状态变化时的回调 - */ - onVisibleChange?: (visible: boolean, reason: string) => void; - - /** - * 弹层触发方式 - */ - popupTriggerType?: 'click' | 'hover'; - - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign?: string; - - /** - * 弹层容器 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层自定义样式 - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层自定义样式类 - */ - popupClassName?: string; - - /** - * 弹层其他属性 - */ - popupProps?: PopupProps; - - /** - * 输入框其他属性 - */ - inputProps?: InputProps; - - /** - * 自定义月份渲染函数 - */ - monthCellRender?: (calendarDate: any) => React.ReactNode; - - /** - * 日期输入框的 aria-label 属性 - */ - dateInputAriaLabel?: string; -} - -export class MonthPicker extends React.Component {} - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; - placeholder?: any; -} - -export interface RangePickerProps extends HTMLAttributesWeak, CommonProps { - name?: string; - type?: 'date' | 'month' | 'year'; - - /** - * 默认展示的起始月份 - */ - defaultVisibleMonth?: () => void; - - /** - * 输入提示 - */ - placeholder?: Array | string; - - /** - * 日期范围值数组 [moment, moment] - */ - value?: Array; - - /** - * 初始的日期范围值数组 [moment, moment] - */ - defaultValue?: Array; - - /** - * 日期格式 - */ - format?: string; - - /** - * 是否使用时间控件,支持传入 TimePicker 的属性 - */ - showTime?: any | boolean; - - /** - * 每次选择是否重置时间(仅在 showTime 开启时有效) - */ - resetTime?: boolean; - - /** - * 禁用日期函数 - */ - disabledDate?: (date: Moment, view: string) => boolean; - - /** - * 自定义面板页脚 - */ - footerRender?: () => React.ReactNode; - - /** - * 日期范围值改变时的回调 [ MomentObject|String, MomentObject|String ] - */ - onChange?: (value: Array) => void; - - /** - * 点击确认按钮时的回调 返回开始时间和结束时间`[ MomentObject|String, MomentObject|String ]` - */ - onOk?: (value: Array) => void; - - /** - * 输入框内置标签 - */ - label?: React.ReactNode; - - /** - * 输入框状态 - */ - state?: 'error' | 'loading' | 'success'; - - /** - * 输入框尺寸 - */ - size?: 'small' | 'medium' | 'large'; - - /** - * 是否禁用 - */ - disabled?: boolean; - - /** - * 是否显示清空按钮 - */ - hasClear?: boolean; - - /** - * 弹层显示状态 - */ - visible?: boolean; - - /** - * 弹层默认是否显示 - */ - defaultVisible?: boolean; - - /** - * 弹层展示状态变化时的回调 - */ - onVisibleChange?: (visible: boolean, reason: string) => void; - - /** - * 弹层触发方式 - */ - popupTriggerType?: 'click' | 'hover'; - - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign?: string; - - /** - * 弹层容器 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层自定义样式 - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层自定义样式类 - */ - popupClassName?: string; - - /** - * 弹层其他属性 - */ - popupProps?: PopupProps; - - /** - * 输入框其他属性 - */ - inputProps?: InputProps; - - /** - * 自定义日期单元格渲染 - */ - dateCellRender?: () => void; - - /** - * 开始日期输入框的 aria-label 属性 - */ - startDateInputAriaLabel?: string; - - /** - * 开始时间输入框的 aria-label 属性 - */ - startTimeInputAriaLabel?: string; - - /** - * 结束日期输入框的 aria-label 属性 - */ - endDateInputAriaLabel?: string; - - /** - * 结束时间输入框的 aria-label 属性 - */ - endTimeInputAriaLabel?: string; -} - -export class RangePicker extends React.Component {} - -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -export interface YearPickerProps extends HTMLAttributesWeak, CommonProps { - name?: string; - /** - * 输入框内置标签 - */ - label?: React.ReactNode; - - /** - * 输入框状态 - */ - state?: 'success' | 'loading' | 'error'; - - /** - * 输入提示 - */ - placeholder?: string; - - /** - * 日期值(受控)moment 对象 - */ - value?: any; - - /** - * 初始日期值,moment 对象 - */ - defaultValue?: any; - - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format?: string; - - /** - * 禁用日期函数 - */ - disabledDate?: (date: Moment, view: string) => boolean; - - /** - * 自定义面板页脚 - */ - footerRender?: () => React.ReactNode; - - /** - * 日期值改变时的回调 - */ - onChange?: (value: {} | string) => void; - - /** - * 输入框尺寸 - */ - size?: 'small' | 'medium' | 'large'; - - /** - * 是否禁用 - */ - disabled?: boolean; - - /** - * 是否显示清空按钮 - */ - hasClear?: boolean; - - /** - * 弹层显示状态 - */ - visible?: boolean; - - /** - * 弹层默认是否显示 - */ - defaultVisible?: boolean; - - /** - * 弹层展示状态变化时的回调 - */ - onVisibleChange?: (visible: boolean, reason: string) => void; - - /** - * 弹层触发方式 - */ - popupTriggerType?: 'click' | 'hover'; - - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign?: string; - - /** - * 弹层容器 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层自定义样式 - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层自定义样式类 - */ - popupClassName?: string; - - /** - * 弹层其他属性 - */ - popupProps?: PopupProps; - - /** - * 输入框其他属性 - */ - inputProps?: InputProps; - - /** - * 日期输入框的 aria-label 属性 - */ - dateInputAriaLabel?: string; -} - -export class YearPicker extends React.Component {} -interface HTMLAttributesWeak extends React.HTMLAttributes { - defaultValue?: any; - onChange?: any; -} - -export interface DatePickerProps extends HTMLAttributesWeak, CommonProps { - name?: string; - /** - * 输入框内置标签 - */ - label?: React.ReactNode; - - /** - * 输入框状态 - */ - state?: 'success' | 'loading' | 'error'; - - /** - * 输入提示 - */ - placeholder?: string; - - /** - * 默认展现的月 - */ - defaultVisibleMonth?: () => Moment; - - /** - * 默认展现的年 - */ - defaultVisibleYear?: () => Moment; - - /** - * 日期值(受控)moment 对象 - */ - value?: any; - - /** - * 初始日期值,moment 对象 - */ - defaultValue?: any; - - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format?: string; - - /** - * 是否使用时间控件,传入 TimePicker 的属性 { defaultValue, format, ... } - */ - showTime?: any | boolean; - - /** - * 每次选择日期时是否重置时间(仅在 showTime 开启时有效) - */ - resetTime?: boolean; - - /** - * 禁用日期函数 - */ - disabledDate?: (date: Moment, view: string) => boolean; - - /** - * 自定义面板页脚 - */ - footerRender?: () => React.ReactNode; - - /** - * 日期值改变时的回调 - */ - onChange?: (value: {} | string) => void; - - /** - * 点击确认按钮时的回调 - */ - onOk?: (value: {} | string) => void; - - /** - * 输入框尺寸 - */ - size?: 'small' | 'medium' | 'large'; - - /** - * 是否禁用 - */ - disabled?: boolean; - - /** - * 是否显示清空按钮 - */ - hasClear?: boolean; - - /** - * 弹层显示状态 - */ - visible?: boolean; - - /** - * 弹层默认是否显示 - */ - defaultVisible?: boolean; - - /** - * 弹层展示状态变化时的回调 - */ - onVisibleChange?: (visible: boolean, reason: string) => void; - - /** - * 弹层展示月份变化时的回调 - */ - onVisibleMonthChange?: (value: Moment, reason: string) => void; - - /** - * 弹层触发方式 - */ - popupTriggerType?: 'click' | 'hover'; - - /** - * 弹层对齐方式,具体含义见 OverLay文档 - */ - popupAlign?: string; - - /** - * 弹层容器 - */ - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - - /** - * 弹层自定义样式 - */ - popupStyle?: React.CSSProperties; - - /** - * 弹层自定义样式类 - */ - popupClassName?: string; - - /** - * 弹层其他属性 - */ - popupProps?: PopupProps; - - /** - * 输入框其他属性 - */ - inputProps?: InputProps; - - /** - * 自定义日期渲染函数 - */ - dateCellRender?: (calendarDate: Moment) => React.ReactNode; - - /** - * 自定义月份渲染函数 - */ - monthCellRender?: (calendarDate: Moment) => React.ReactNode; - - /** - * 自定义年份渲染函数 - */ - yearCellRender?: (calendarDate: Moment) => React.ReactNode; - - /** - * 日期输入框的 aria-label 属性 - */ - dateInputAriaLabel?: string; - - /** - * 时间输入框的 aria-label 属性 - */ - timeInputAriaLabel?: string; - - /** - * 是否为预览态 - */ - isPreview?: boolean; - - renderPreview?: (value: any) => React.ReactNode; - - /** - * 是否跟随滚动 - */ - followTrigger?: boolean; - - /** - * 自定义弹层 - */ - popupComponent?: React.ComponentType; - - /** - * 自定义弹层内容 - */ - popupContent?: React.ReactNode; - - /** - * 禁用日期选择器的日期模式切换 - */ - disableChangeMode?: boolean; -} - -export default class DatePicker extends React.Component { - static MonthPicker: typeof MonthPicker; - static RangePicker: typeof RangePicker; - static YearPicker: typeof YearPicker; - static WeekPicker: React.ComponentType; -} diff --git a/components/date-picker/index.jsx b/components/date-picker/index.jsx deleted file mode 100644 index 7040f4c72b..0000000000 --- a/components/date-picker/index.jsx +++ /dev/null @@ -1,79 +0,0 @@ -import ConfigProvider from '../config-provider'; -import DatePicker from './date-picker'; -import RangePicker from './range-picker'; -import MonthPicker from './month-picker'; -import YearPicker from './year-picker'; -import WeekPicker from './week-picker'; - -/* istanbul ignore next */ -const transform = (props, deprecated) => { - const { open, defaultOpen, onOpenChange, ...others } = props; - const newProps = others; - - delete newProps.formater; - - if ('open' in props) { - deprecated('open', 'visible', 'DatePicker'); - - newProps.visible = open; - - if ('visible' in props) { - newProps.visible = props.visible; - } - } - - if ('defaultOpen' in props) { - deprecated('defaultOpen', 'defaultVisible', 'DatePicker'); - - newProps.defaultVisible = defaultOpen; - - if ('defaultVisible' in props) { - newProps.defaultVisible = props.defaultVisible; - } - } - - if ('onOpenChange' in props && typeof onOpenChange === 'function') { - deprecated('onOpenChange', 'onVisibleChange', 'DatePicker'); - - newProps.onVisibleChange = onOpenChange; - - if ('onVisibleChange' in props) { - newProps.onVisibleChange = props.onVisibleChange; - } - } - - if ('formater' in props) { - deprecated('formater', 'format showTime.format', 'DatePicker'); - } - - if ('format' in props && typeof props.format !== 'string') { - deprecated('format', 'format: PropTypes.string,', 'DatePicker'); - } - - if ('ranges' in props) { - deprecated('ranges', 'footerRender: PropTypes.func', 'RangePicker'); - } - - return newProps; -}; - -DatePicker.RangePicker = ConfigProvider.config(RangePicker, { - componentName: 'DatePicker', - transform, -}); -DatePicker.MonthPicker = ConfigProvider.config(MonthPicker, { - componentName: 'DatePicker', - transform, -}); -DatePicker.YearPicker = ConfigProvider.config(YearPicker, { - componentName: 'DatePicker', - transform, -}); - -DatePicker.WeekPicker = ConfigProvider.config(WeekPicker, { - componentName: 'DatePicker', -}); - -export default ConfigProvider.config(DatePicker, { - transform, -}); diff --git a/components/date-picker/index.tsx b/components/date-picker/index.tsx new file mode 100644 index 0000000000..5667aff9bc --- /dev/null +++ b/components/date-picker/index.tsx @@ -0,0 +1,93 @@ +import ConfigProvider from '../config-provider'; +import DatePicker from './date-picker'; +import RangePicker from './range-picker'; +import MonthPicker from './month-picker'; +import YearPicker from './year-picker'; +import WeekPicker from './week-picker'; +import { assignSubComponent } from '../util/component'; +import type { log } from '../util'; +import type { DeprecatedProps } from './types'; + +export type { + DatePickerProps, + RangePickerProps, + MonthPickerProps, + YearPickerProps, + WeekPickerProps, +} from './types'; + +const transform = ( + props: Record & DeprecatedProps, + deprecated: typeof log.deprecated +) => { + const { open, defaultOpen, onOpenChange, ...others } = props; + const newProps = others; + + delete newProps.formater; + + if ('open' in props) { + deprecated('open', 'visible', 'DatePicker'); + + newProps.visible = open; + + if ('visible' in props) { + newProps.visible = props.visible; + } + } + + if ('defaultOpen' in props) { + deprecated('defaultOpen', 'defaultVisible', 'DatePicker'); + + newProps.defaultVisible = defaultOpen; + + if ('defaultVisible' in props) { + newProps.defaultVisible = props.defaultVisible; + } + } + + if ('onOpenChange' in props && typeof onOpenChange === 'function') { + deprecated('onOpenChange', 'onVisibleChange', 'DatePicker'); + + newProps.onVisibleChange = onOpenChange; + + if ('onVisibleChange' in props) { + newProps.onVisibleChange = props.onVisibleChange; + } + } + + if ('formater' in props) { + deprecated('formater', 'format showTime.format', 'DatePicker'); + } + + if ('format' in props && typeof props.format !== 'string') { + deprecated('format', 'format: PropTypes.string,', 'DatePicker'); + } + + if ('ranges' in props) { + deprecated('ranges', 'footerRender: PropTypes.func', 'RangePicker'); + } + + return newProps; +}; + +const DatePickerWithSub = assignSubComponent(DatePicker, { + RangePicker: ConfigProvider.config(RangePicker, { + componentName: 'DatePicker', + transform, + }), + MonthPicker: ConfigProvider.config(MonthPicker, { + componentName: 'DatePicker', + transform, + }), + YearPicker: ConfigProvider.config(YearPicker, { + componentName: 'DatePicker', + transform, + }), + WeekPicker: ConfigProvider.config(WeekPicker, { + componentName: 'DatePicker', + }), +}); + +export default ConfigProvider.config(DatePickerWithSub, { + transform, +}); diff --git a/components/date-picker/mobile/index.jsx b/components/date-picker/mobile/index.tsx similarity index 100% rename from components/date-picker/mobile/index.jsx rename to components/date-picker/mobile/index.tsx diff --git a/components/date-picker/module/panel-footer.jsx b/components/date-picker/module/panel-footer.jsx deleted file mode 100644 index 66917ae922..0000000000 --- a/components/date-picker/module/panel-footer.jsx +++ /dev/null @@ -1,83 +0,0 @@ -import React from 'react'; -import moment from 'moment'; -import Button from '../../button'; -import { func } from '../../util'; -import { PANEL } from '../util'; - -class PanelFooter extends React.PureComponent { - static defaultProps = { - // onPanelChange: func.noop, - onOk: func.noop, - }; - - changePanel = () => { - const targetPanel = { - [PANEL.DATE]: PANEL.TIME, - [PANEL.TIME]: PANEL.DATE, - }[this.props.panel]; - this.props.onPanelChange(targetPanel); - }; - - createRanges = ranges => { - if (!ranges || ranges.length === 0) return null; - const { onOk, prefix } = this.props; - - return ( -
    - {ranges.map(({ label, value = [], onChange }) => { - const handleClick = () => { - const momentValue = value.map(v => moment(v)); - - onChange(momentValue); - onOk(momentValue); - }; - return ( - - ); - })} -
    - ); - }; - - render() { - const { - prefix, - locale, - panel, - value, - ranges, // 兼容0.x range 属性 - disabledOk, - onPanelChange, - onOk, - } = this.props; - const panelBtnLabel = { - [PANEL.DATE]: locale.selectTime, - [PANEL.TIME]: locale.selectDate, - }[panel]; - - const sharedBtnProps = { - size: 'small', - type: 'primary', - disabled: !value, - }; - const onClick = () => onOk(); - - return ( -
    - {this.createRanges(ranges)} - {onPanelChange ? ( - - ) : null} - -
    - ); - } -} - -export default PanelFooter; diff --git a/components/date-picker/module/panel-footer.tsx b/components/date-picker/module/panel-footer.tsx new file mode 100644 index 0000000000..2b480e30e6 --- /dev/null +++ b/components/date-picker/module/panel-footer.tsx @@ -0,0 +1,86 @@ +import React from 'react'; +import moment from 'moment'; +import Button from '../../button'; +import { func } from '../../util'; +import { PANEL } from '../util'; +import type { PanelFooterProps } from '../types'; + +class PanelFooter extends React.PureComponent< + isRange extends true ? PanelFooterProps : Omit & { onOk: () => void } +> { + static displayName = 'PanelFooter'; + static defaultProps = { + onOk: func.noop, + }; + + changePanel = () => { + const targetPanel = { + [PANEL.DATE]: PANEL.TIME, + [PANEL.TIME]: PANEL.DATE, + }[this.props.panel]; + this.props.onPanelChange!(targetPanel); + }; + + createRanges = (ranges: PanelFooterProps['ranges']) => { + if (!ranges || ranges.length === 0) return null; + const { onOk, prefix } = this.props; + + return ( +
    + {ranges.map(({ label, value = [], onChange }) => { + const handleClick = () => { + const momentValue = value.map(v => moment(v)); + + onChange(momentValue); + onOk(momentValue); + }; + return ( + + ); + })} +
    + ); + }; + + render() { + const { + prefix, + locale, + panel, + value, + ranges, // 兼容 0.x range 属性 + disabledOk, + onPanelChange, + onOk, + } = this.props; + const panelBtnLabel = { + [PANEL.DATE]: locale.selectTime, + [PANEL.TIME]: locale.selectDate, + }[panel]; + + const sharedBtnProps = { + size: 'small', + type: 'primary', + disabled: !value, + } as const; + const onClick = () => onOk(); + + return ( +
    + {this.createRanges(ranges)} + {onPanelChange ? ( + + ) : null} + +
    + ); + } +} + +export default PanelFooter; diff --git a/components/date-picker/month-picker.jsx b/components/date-picker/month-picker.jsx deleted file mode 100644 index aaeaf8d4c8..0000000000 --- a/components/date-picker/month-picker.jsx +++ /dev/null @@ -1,473 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import classnames from 'classnames'; -import moment from 'moment'; -import ConfigProvider from '../config-provider'; -import Overlay from '../overlay'; -import Input from '../input'; -import Icon from '../icon'; -import Calendar from '../calendar'; -import nextLocale from '../locale/zh-cn'; -import { func, obj } from '../util'; -import { checkDateValue, formatDateValue, onDateKeydown } from './util'; - -const { Popup } = Overlay; - -/** - * DatePicker.MonthPicker - */ -class MonthPicker extends Component { - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 输入框内置标签 - */ - label: PropTypes.node, - /** - * 输入框状态 - */ - state: PropTypes.oneOf(['success', 'loading', 'error']), - /** - * 输入提示 - */ - placeholder: PropTypes.string, - /** - * 默认展现的年 - * @return {MomentObject} 返回包含指定年份的 moment 对象实例 - */ - defaultVisibleYear: PropTypes.func, - /** - * 日期值(受控)moment 对象 - */ - value: checkDateValue, - /** - * 初始日期值,moment 对象 - */ - defaultValue: checkDateValue, - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format: PropTypes.string, - /** - * 禁用日期函数 - * @param {MomentObject} 日期值 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @return {Boolean} 是否禁用 - */ - disabledDate: PropTypes.func, - /** - * 自定义面板页脚 - * @return {Node} 自定义的面板页脚组件 - */ - footerRender: PropTypes.func, - /** - * 日期值改变时的回调 - * @param {MomentObject|String} value 日期值 - */ - onChange: PropTypes.func, - /** - * 输入框尺寸 - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * 是否禁用 - */ - disabled: PropTypes.bool, - /** - * 是否显示清空按钮 - */ - hasClear: PropTypes.bool, - /** - * 弹层显示状态 - */ - visible: PropTypes.bool, - /** - * 弹层默认是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层展示状态变化时的回调 - * @param {Boolean} visible 弹层是否显示 - * @param {String} type 触发弹层显示和隐藏的来源 calendarSelect 表示由日期表盘的选择触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 弹层触发方式 - */ - popupTriggerType: PropTypes.oneOf(['click', 'hover']), - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign: PropTypes.string, - /** - * 弹层容器 - * @param {Element} target 目标元素 - * @return {Element} 弹层的容器元素 - */ - popupContainer: PropTypes.any, - /** - * 弹层自定义样式 - */ - popupStyle: PropTypes.object, - /** - * 弹层自定义样式类 - */ - popupClassName: PropTypes.string, - /** - * 弹层其他属性 - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 输入框其他属性 - */ - inputProps: PropTypes.object, - /** - * 自定义月份渲染函数 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @returns {ReactNode} - */ - monthCellRender: PropTypes.func, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - /** - * 日期输入框的 aria-label 属性 - */ - dateInputAriaLabel: PropTypes.string, - /** - * 是否为预览态 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {MomentObject} value 月份 - */ - renderPreview: PropTypes.func, - locale: PropTypes.object, - className: PropTypes.string, - name: PropTypes.string, - popupComponent: PropTypes.elementType, - popupContent: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - format: 'YYYY-MM', - size: 'medium', - disabledDate: () => false, - footerRender: () => null, - hasClear: true, - popupTriggerType: 'click', - popupAlign: 'tl tl', - locale: nextLocale.DatePicker, - onChange: func.noop, - onVisibleChange: func.noop, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - value: formatDateValue(props.defaultValue, props.format), - dateInputStr: '', - inputing: false, - visible: props.defaultVisible, - inputAsString: typeof props.defaultValue === 'string', - }; - } - - static getDerivedStateFromProps(props) { - const states = {}; - if ('value' in props) { - states.value = formatDateValue(props.value, props.format); - states.inputAsString = typeof props.value === 'string'; - } - - if ('visible' in props) { - states.visible = props.visible; - } - - return states; - } - - onValueChange = newValue => { - const ret = this.state.inputAsString && newValue ? newValue.format(this.props.format) : newValue; - this.props.onChange(ret); - }; - - onSelectCalendarPanel = value => { - // const { format } = this.props; - const prevSelectedMonth = this.state.value; - const selectedMonth = value - .clone() - .date(1) - .hour(0) - .minute(0) - .second(0); - - this.handleChange(selectedMonth, prevSelectedMonth, { inputing: false }, () => { - this.onVisibleChange(false, 'calendarSelect'); - }); - }; - - clearValue = () => { - this.setState({ - dateInputStr: '', - }); - - this.handleChange(null, this.state.value); - }; - - onDateInputChange = (inputStr, e, eventType) => { - if (eventType === 'clear' || !inputStr) { - e.stopPropagation(); - this.clearValue(); - } else { - this.setState({ - dateInputStr: inputStr, - inputing: true, - }); - } - }; - - onDateInputBlur = () => { - const { dateInputStr } = this.state; - if (dateInputStr) { - const { disabledDate, format } = this.props; - const parsed = moment(dateInputStr, format, true); - - this.setState({ - dateInputStr: '', - inputing: false, - }); - - if (parsed.isValid() && !disabledDate(parsed, 'month')) { - this.handleChange(parsed, this.state.value); - } - } - }; - - onKeyDown = e => { - const { format } = this.props; - const { dateInputStr, value } = this.state; - const dateStr = onDateKeydown(e, { format, dateInputStr, value }, 'month'); - if (!dateStr) return; - this.onDateInputChange(dateStr); - }; - - handleChange = (newValue, prevValue, others = {}, callback) => { - if (!('value' in this.props)) { - this.setState({ - value: newValue, - ...others, - }); - } else { - this.setState({ - ...others, - }); - } - - const { format } = this.props; - - const newValueOf = newValue ? newValue.format(format) : null; - const preValueOf = prevValue ? prevValue.format(format) : null; - - if (newValueOf !== preValueOf) { - this.onValueChange(newValue); - if (typeof callback === 'function') { - return callback(); - } - } - }; - - onVisibleChange = (visible, type) => { - if (!('visible' in this.props)) { - this.setState({ - visible, - }); - } - this.props.onVisibleChange(visible, type); - }; - - renderPreview(others) { - const { prefix, format, className, renderPreview } = this.props; - const { value } = this.state; - const previewCls = classnames(className, `${prefix}form-preview`); - - const label = value ? value.format(format) : ''; - - if (typeof renderPreview === 'function') { - return ( -
    - {renderPreview(value, this.props)} -
    - ); - } - - return ( -

    - {label} -

    - ); - } - - render() { - const { - prefix, - rtl, - locale, - label, - state, - format, - defaultVisibleYear, - disabledDate, - footerRender, - placeholder, - size, - disabled, - hasClear, - popupTriggerType, - popupAlign, - popupContainer, - popupStyle, - popupClassName, - popupProps, - popupComponent, - popupContent, - followTrigger, - className, - inputProps, - monthCellRender, - yearCellRender, - dateInputAriaLabel, - isPreview, - ...others - } = this.props; - - const { visible, value, dateInputStr, inputing } = this.state; - - const monthPickerCls = classnames( - { - [`${prefix}month-picker`]: true, - }, - className - ); - - const triggerInputCls = classnames({ - [`${prefix}month-picker-input`]: true, - [`${prefix}error`]: false, - }); - - const panelBodyClassName = classnames({ - [`${prefix}month-picker-body`]: true, - }); - - if (rtl) { - others.dir = 'rtl'; - } - - if (isPreview) { - return this.renderPreview(obj.pickOthers(others, MonthPicker.PropTypes)); - } - - const panelInputCls = `${prefix}month-picker-panel-input`; - - const sharedInputProps = { - ...inputProps, - size, - disabled, - onChange: this.onDateInputChange, - onBlur: this.onDateInputBlur, - onPressEnter: this.onDateInputBlur, - onKeyDown: this.onKeyDown, - }; - - const dateInputValue = inputing ? dateInputStr : (value && value.format(format)) || ''; - const triggerInputValue = dateInputValue; - - const dateInput = ( - - ); - - const datePanel = ( - - ); - - const panelBody = datePanel; - const panelFooter = footerRender(); - - const allowClear = value && hasClear; - const trigger = ( -
    - } - hasClear={allowClear} - className={triggerInputCls} - /> -
    - ); - - const PopupComponent = popupComponent ? popupComponent : Popup; - - return ( -
    - - {popupContent ? ( - popupContent - ) : ( -
    -
    {dateInput}
    - {panelBody} - {panelFooter} -
    - )} -
    -
    - ); - } -} - -export default polyfill(MonthPicker); diff --git a/components/date-picker/month-picker.tsx b/components/date-picker/month-picker.tsx new file mode 100644 index 0000000000..539b095bbe --- /dev/null +++ b/components/date-picker/month-picker.tsx @@ -0,0 +1,408 @@ +import React, { + Component, + type HTMLAttributes, + type SyntheticEvent, + type KeyboardEvent, +} from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import ConfigProvider from '../config-provider'; +import Overlay from '../overlay'; +import Input from '../input'; +import Icon from '../icon'; +import Calendar from '../calendar'; +import nextLocale from '../locale/zh-cn'; +import { type ClassPropsWithDefault, func, obj } from '../util'; +import { checkDateValue, formatDateValue, onDateKeydown } from './util'; +import type { MonthPickerProps, MonthPickerState } from './types'; + +const { Popup } = Overlay; + +type InnerMonthPickerProps = ClassPropsWithDefault< + MonthPickerProps, + typeof MonthPicker.defaultProps +>; + +/** + * DatePicker.MonthPicker + */ +class MonthPicker extends Component { + static displayName = 'MonthPicker'; + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + label: PropTypes.node, + state: PropTypes.oneOf(['success', 'loading', 'error']), + placeholder: PropTypes.string, + defaultVisibleYear: PropTypes.func, + value: checkDateValue, + defaultValue: checkDateValue, + format: PropTypes.string, + disabledDate: PropTypes.func, + footerRender: PropTypes.func, + onChange: PropTypes.func, + size: PropTypes.oneOf(['small', 'medium', 'large']), + disabled: PropTypes.bool, + hasClear: PropTypes.bool, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + popupTriggerType: PropTypes.oneOf(['click', 'hover']), + popupAlign: PropTypes.string, + popupContainer: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + inputProps: PropTypes.object, + monthCellRender: PropTypes.func, + yearCellRender: PropTypes.func, + dateInputAriaLabel: PropTypes.string, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + locale: PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + popupComponent: PropTypes.elementType, + popupContent: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + format: 'YYYY-MM', + size: 'medium', + disabledDate: () => false, + footerRender: () => null, + hasClear: true, + popupTriggerType: 'click', + popupAlign: 'tl tl', + locale: nextLocale.DatePicker, + onChange: func.noop, + onVisibleChange: func.noop, + }; + + readonly props: InnerMonthPickerProps; + + constructor(props: MonthPickerProps) { + super(props); + + this.state = { + value: formatDateValue(props.defaultValue, props.format), + dateInputStr: '', + inputing: false, + visible: props.defaultVisible, + inputAsString: typeof props.defaultValue === 'string', + }; + } + + static getDerivedStateFromProps(props: InnerMonthPickerProps) { + const states: Partial = {}; + if ('value' in props) { + states.value = formatDateValue(props.value, props.format); + if (typeof props.value === 'string') { + states.inputAsString = true; + } + if (moment.isMoment(props.value)) { + states.inputAsString = false; + } + } + + if ('visible' in props) { + states.visible = props.visible; + } + + return states; + } + + onValueChange = (newValue: Moment | null) => { + const ret = + this.state.inputAsString && newValue ? newValue.format(this.props.format) : newValue; + this.props.onChange(ret); + }; + + onSelectCalendarPanel = (value: Moment) => { + const prevSelectedMonth = this.state.value; + const selectedMonth = value.clone().date(1).hour(0).minute(0).second(0); + + this.handleChange(selectedMonth, prevSelectedMonth, { inputing: false }, () => { + this.onVisibleChange(false, 'calendarSelect'); + }); + }; + + clearValue = () => { + this.setState({ + dateInputStr: '', + }); + + this.handleChange(null, this.state.value); + }; + + onDateInputChange = ( + inputStr: string, + e: SyntheticEvent, + eventType?: string + ) => { + if (eventType === 'clear' || !inputStr) { + e.stopPropagation(); + this.clearValue(); + } else { + this.setState({ + dateInputStr: inputStr, + inputing: true, + }); + } + }; + + onDateInputBlur = () => { + const { dateInputStr } = this.state; + if (dateInputStr) { + const { disabledDate, format } = this.props; + const parsed = moment(dateInputStr, format, true); + + this.setState({ + dateInputStr: '', + inputing: false, + }); + + if (parsed.isValid() && !disabledDate(parsed, 'month')) { + this.handleChange(parsed, this.state.value); + } + } + }; + + onKeyDown = (e: KeyboardEvent) => { + const { format } = this.props; + const { dateInputStr, value } = this.state; + const dateStr = onDateKeydown(e, { format, dateInputStr, value }, 'month'); + if (!dateStr) return; + // @ts-expect-error 应传入 e + this.onDateInputChange(dateStr); + }; + + handleChange = ( + newValue: Moment | null, + prevValue: Moment | null, + others = {}, + callback?: () => void + ) => { + if (!('value' in this.props)) { + this.setState({ + value: newValue, + ...others, + }); + } else { + this.setState({ + ...others, + }); + } + + const { format } = this.props; + + const newValueOf = newValue ? newValue.format(format) : null; + const preValueOf = prevValue ? prevValue.format(format) : null; + + if (newValueOf !== preValueOf) { + this.onValueChange(newValue); + if (typeof callback === 'function') { + return callback(); + } + } + }; + + onVisibleChange = (visible: boolean, type: string) => { + if (!('visible' in this.props)) { + this.setState({ + visible, + }); + } + this.props.onVisibleChange(visible, type); + }; + + renderPreview(others: HTMLAttributes) { + const { prefix, format, className, renderPreview } = this.props; + const { value } = this.state; + const previewCls = classnames(className, `${prefix}form-preview`); + + const label = value ? value.format(format) : ''; + + if (typeof renderPreview === 'function') { + return ( +
    + {renderPreview(value, this.props)} +
    + ); + } + + return ( +

    + {label} +

    + ); + } + + render() { + const { + prefix, + rtl, + locale, + label, + state, + format, + defaultVisibleYear, + disabledDate, + footerRender, + placeholder, + size, + disabled, + hasClear, + popupTriggerType, + popupAlign, + popupContainer, + popupStyle, + popupClassName, + popupProps, + popupComponent, + popupContent, + followTrigger, + className, + inputProps, + monthCellRender, + yearCellRender, + dateInputAriaLabel, + isPreview, + ...others + } = this.props; + + const { visible, value, dateInputStr, inputing } = this.state; + + const monthPickerCls = classnames( + { + [`${prefix}month-picker`]: true, + }, + className + ); + + const triggerInputCls = classnames({ + [`${prefix}month-picker-input`]: true, + [`${prefix}error`]: false, + }); + + const panelBodyClassName = classnames({ + [`${prefix}month-picker-body`]: true, + }); + + if (rtl) { + others.dir = 'rtl'; + } + + if (isPreview) { + // @ts-expect-error 应是 propTypes + return this.renderPreview(obj.pickOthers(others, MonthPicker.PropTypes)); + } + + const panelInputCls = `${prefix}month-picker-panel-input`; + + const sharedInputProps = { + ...inputProps, + size, + disabled, + onChange: this.onDateInputChange, + onBlur: this.onDateInputBlur, + onPressEnter: this.onDateInputBlur, + onKeyDown: this.onKeyDown, + }; + + const dateInputValue = inputing ? dateInputStr : (value && value.format(format)) || ''; + const triggerInputValue = dateInputValue; + + const dateInput = ( + + ); + + const datePanel = ( + + ); + + const panelBody = datePanel; + const panelFooter = footerRender(); + + const allowClear = value && hasClear; + const trigger = ( +
    + + } + // @ts-expect-error allowClear 应该先做 boolean 化处理 + hasClear={allowClear} + className={triggerInputCls} + /> +
    + ); + + const PopupComponent = popupComponent ? popupComponent : Popup; + + return ( +
    + + {popupContent ? ( + popupContent + ) : ( +
    +
    {dateInput}
    + {panelBody} + {panelFooter} +
    + )} +
    +
    + ); + } +} + +export default polyfill(MonthPicker); diff --git a/components/date-picker/range-picker.jsx b/components/date-picker/range-picker.jsx deleted file mode 100644 index 2c6a8f20eb..0000000000 --- a/components/date-picker/range-picker.jsx +++ /dev/null @@ -1,1123 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import classnames from 'classnames'; -import moment from 'moment'; -import ConfigProvider from '../config-provider'; -import Overlay from '../overlay'; -import Input from '../input'; -import Icon from '../icon'; -import Calendar from '../calendar'; -import RangeCalendar from '../calendar/range-calendar'; -import TimePickerPanel from '../time-picker/panel'; -import nextLocale from '../locale/zh-cn'; -import { func, obj } from '../util'; -import { - PANEL, - resetValueTime, - formatDateValue, - getDateTimeFormat, - isFunction, - onDateKeydown, - onTimeKeydown, -} from './util'; -import PanelFooter from './module/panel-footer'; - -const { Popup } = Overlay; - -function mapInputStateName(name) { - return { - startValue: 'startDateInputStr', - endValue: 'endDateInputStr', - startTime: 'startTimeInputStr', - endTime: 'endTimeInputStr', - }[name]; -} - -function mapTimeToValue(name) { - return { - startTime: 'startValue', - endTime: 'endValue', - }[name]; -} - -function getFormatValues(values, format) { - if (!Array.isArray(values)) { - return [null, null]; - } - return [formatDateValue(values[0], format), formatDateValue(values[1], format)]; -} - -/** - * DatePicker.RangePicker - */ -class RangePicker extends Component { - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 日期范围类型 - */ - type: PropTypes.oneOf(['date', 'month', 'year']), - /** - * 默认展示的起始月份 - * @return {MomentObject} 返回包含指定月份的 moment 对象实例 - */ - defaultVisibleMonth: PropTypes.func, - onVisibleMonthChange: PropTypes.func, - /** - * 日期范围值数组 [moment, moment] - */ - value: PropTypes.array, - /** - * 初始的日期范围值数组 [moment, moment] - */ - defaultValue: PropTypes.array, - /** - * 日期格式 - */ - format: PropTypes.string, - /** - * 是否使用时间控件,支持传入 TimePicker 的属性 - */ - showTime: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - /** - * 每次选择是否重置时间(仅在 showTime 开启时有效) - */ - resetTime: PropTypes.bool, - /** - * 禁用日期函数 - * @param {MomentObject} 日期值 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @return {Boolean} 是否禁用 - */ - disabledDate: PropTypes.func, - /** - * 自定义面板页脚 - * @return {Node} 自定义的面板页脚组件 - */ - footerRender: PropTypes.func, - /** - * 日期范围值改变时的回调 [ MomentObject|String, MomentObject|String ] - * @param {Array} value 日期值 - */ - onChange: PropTypes.func, - /** - * 点击确认按钮时的回调 返回开始时间和结束时间`[ MomentObject|String, MomentObject|String ]` - * @return {Array} 日期范围 - */ - onOk: PropTypes.func, - /** - * 输入框内置标签 - */ - label: PropTypes.node, - /** - * 输入框状态 - */ - state: PropTypes.oneOf(['error', 'loading', 'success']), - /** - * 输入框尺寸 - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * 是否禁用 - */ - disabled: PropTypes.bool, - /** - * 是否显示清空按钮 - */ - hasClear: PropTypes.bool, - /** - * 弹层显示状态 - */ - visible: PropTypes.bool, - /** - * 弹层默认是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层展示状态变化时的回调 - * @param {Boolean} visible 弹层是否显示 - * @param {String} type 触发弹层显示和隐藏的来源 okBtnClick 表示由确认按钮触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 弹层触发方式 - */ - popupTriggerType: PropTypes.oneOf(['click', 'hover']), - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign: PropTypes.string, - /** - * 弹层容器 - * @param {Element} target 目标元素 - * @return {Element} 弹层的容器元素 - */ - popupContainer: PropTypes.any, - /** - * 弹层自定义样式 - */ - popupStyle: PropTypes.object, - /** - * 弹层自定义样式类 - */ - popupClassName: PropTypes.string, - /** - * 弹层其他属性 - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 输入框其他属性 - */ - inputProps: PropTypes.object, - /** - * 自定义日期单元格渲染 - */ - dateCellRender: PropTypes.func, - /** - * 自定义月份渲染函数 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @returns {ReactNode} - */ - monthCellRender: PropTypes.func, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - /** - * 开始日期输入框的 aria-label 属性 - */ - startDateInputAriaLabel: PropTypes.string, - /** - * 开始时间输入框的 aria-label 属性 - */ - startTimeInputAriaLabel: PropTypes.string, - /** - * 结束日期输入框的 aria-label 属性 - */ - endDateInputAriaLabel: PropTypes.string, - /** - * 结束时间输入框的 aria-label 属性 - */ - endTimeInputAriaLabel: PropTypes.string, - /** - * 是否为预览态 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {Array} value 日期区间 - */ - renderPreview: PropTypes.func, - disableChangeMode: PropTypes.bool, - yearRange: PropTypes.arrayOf(PropTypes.number), - ranges: PropTypes.object, // 兼容0.x版本 - locale: PropTypes.object, - className: PropTypes.string, - name: PropTypes.string, - popupComponent: PropTypes.elementType, - popupContent: PropTypes.node, - placeholder: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - type: 'date', - size: 'medium', - showTime: false, - resetTime: false, - disabledDate: () => false, - footerRender: () => null, - hasClear: true, - defaultVisible: false, - popupTriggerType: 'click', - popupAlign: 'tl tl', - locale: nextLocale.DatePicker, - disableChangeMode: false, - onChange: func.noop, - onOk: func.noop, - onVisibleChange: func.noop, - }; - - constructor(props, context) { - super(props, context); - const { format, timeFormat, dateTimeFormat } = getDateTimeFormat(props.format, props.showTime, props.type); - - const val = props.value || props.defaultValue; - const values = getFormatValues(val, dateTimeFormat); - - this.state = { - visible: props.visible || props.defaultVisible, - startValue: values[0], - endValue: values[1], - startDateInputStr: '', - endDateInputStr: '', - activeDateInput: 'startValue', - startTimeInputStr: '', - endTimeInputStr: '', - inputing: false, // 当前是否处于输入状态 - panel: PANEL.DATE, - format, - timeFormat, - dateTimeFormat, - inputAsString: val && (typeof val[0] === 'string' || typeof val[1] === 'string'), - }; - } - static getDerivedStateFromProps(props) { - const formatStates = getDateTimeFormat(props.format, props.showTime, props.type); - const states = {}; - - if ('value' in props) { - const values = getFormatValues(props.value, formatStates.dateTimeFormat); - states.startValue = values[0]; - states.endValue = values[1]; - states.inputAsString = - props.value && (typeof props.value[0] === 'string' || typeof props.value[1] === 'string'); - } - - if ('visible' in props) { - states.visible = props.visible; - } - - return { - ...states, - ...formatStates, - }; - } - - onValueChange = (values, handler = 'onChange') => { - let ret; - if (!values.length || !this.state.inputAsString) { - ret = values; - } else { - ret = [ - values[0] ? values[0].format(this.state.dateTimeFormat) : null, - values[1] ? values[1].format(this.state.dateTimeFormat) : null, - ]; - } - this.props[handler](ret); - }; - - onSelectCalendarPanel = (value, active) => { - const { showTime, resetTime } = this.props; - const { - activeDateInput: prevActiveDateInput, - startValue: prevStartValue, - endValue: prevEndValue, - timeFormat, - } = this.state; - - const newState = { - activeDateInput: active || prevActiveDateInput, - inputing: false, - }; - - let newValue = value; - - switch (active || prevActiveDateInput) { - case 'startValue': { - if (!prevEndValue || value.valueOf() <= prevEndValue.valueOf()) { - newState.activeDateInput = 'endValue'; - } - - if (showTime) { - if (!prevStartValue) { - // 第一次选择,如果设置了时间默认值,则使用该默认时间 - if (showTime.defaultValue) { - const defaultTimeValue = formatDateValue( - Array.isArray(showTime.defaultValue) ? showTime.defaultValue[0] : showTime.defaultValue, - timeFormat - ); - newValue = resetValueTime(value, defaultTimeValue); - } - } else if (!resetTime) { - // 非第一次选择,如果开启了 resetTime ,则记住之前选择的时间值 - newValue = resetValueTime(value, prevStartValue); - } - } - - newState.startValue = newValue; - - // 如果起始日期大于结束日期 - if (prevEndValue && newValue.valueOf() > prevEndValue.valueOf()) { - // 将结束日期设置为起始日期 如果需要的话保留时间 - newState.endValue = resetTime ? newValue : resetValueTime(value, prevEndValue); - - // 如果结束日期不大于起始日期则将结束日期设置为等于开始日期 - if (newState.endValue.valueOf() < newState.startValue.valueOf()) { - newState.endValue = moment(newState.startValue); - } - newState.activeDateInput = 'endValue'; - } - break; - } - - case 'endValue': - if (!prevStartValue) { - newState.activeDateInput = 'startValue'; - } - - if (showTime) { - if (!prevEndValue) { - // 第一次选择,如果设置了时间默认值,则使用该默认时间 - if (showTime.defaultValue) { - const defaultTimeValue = formatDateValue( - Array.isArray(showTime.defaultValue) - ? showTime.defaultValue[1] || showTime.defaultValue[0] - : showTime.defaultValue, - timeFormat - ); - newValue = resetValueTime(value, defaultTimeValue); - } - } else if (!resetTime) { - // 非第一次选择,如果开启了 resetTime ,则记住之前选择的时间值 - newValue = resetValueTime(value, prevEndValue); - } - } - - newState.endValue = newValue; - - // 选择了一个比开始日期更小的结束日期,此时表示用户重新选择了 - if (prevStartValue && newValue.valueOf() <= prevStartValue.valueOf()) { - newState.startValue = resetTime ? value : resetValueTime(value, prevStartValue); - newState.endValue = resetTime ? value : resetValueTime(value, prevEndValue); - - // 如果结束日期不大于起始日期则将结束日期设置为等于开始日期 - if (newState.endValue.valueOf() < newState.startValue.valueOf()) { - newState.endValue = moment(newState.startValue); - } - } - break; - } - - const newStartValue = 'startValue' in newState ? newState.startValue : prevStartValue; - const newEndValue = 'endValue' in newState ? newState.endValue : prevEndValue; - - // 受控状态选择不更新值 - if ('value' in this.props) { - delete newState.startValue; - delete newState.endValue; - } - - this.setState(newState); - - this.onValueChange([newStartValue, newEndValue]); - }; - - clearRange = () => { - this.setState({ - startDateInputStr: '', - endDateInputStr: '', - startTimeInputStr: '', - endTimeInputStr: '', - }); - - if (!('value' in this.props)) { - this.setState({ - startValue: null, - endValue: null, - }); - } - - this.onValueChange([]); - }; - - onDateInputChange = (inputStr, e, eventType) => { - if (eventType === 'clear' || !inputStr) { - e.stopPropagation(); - this.clearRange(); - } else { - const stateName = mapInputStateName(this.state.activeDateInput); - this.setState({ - [stateName]: inputStr, - inputing: this.state.activeDateInput, - }); - } - }; - - onDateInputBlur = () => { - const { resetTime } = this.props; - const { activeDateInput } = this.state; - const stateName = mapInputStateName(activeDateInput); - const dateInputStr = this.state[stateName]; - - if (dateInputStr) { - const { format, disabledDate } = this.props; - const parsed = moment(dateInputStr, format, true); - - this.setState({ - [stateName]: '', - inputing: false, - }); - - if (parsed.isValid() && !disabledDate(parsed, 'date')) { - const valueName = activeDateInput; - const newValue = resetTime ? parsed : resetValueTime(parsed, this.state[activeDateInput]); - - this.handleChange(valueName, newValue); - } - } - }; - - onDateInputKeyDown = e => { - const { type } = this.props; - const { activeDateInput, format } = this.state; - const stateName = mapInputStateName(activeDateInput); - const dateInputStr = this.state[stateName]; - const dateStr = onDateKeydown( - e, - { - format, - value: this.state[activeDateInput], - dateInputStr, - }, - type === 'date' ? 'day' : type - ); - if (!dateStr) return; - - return this.onDateInputChange(dateStr); - }; - - onFocusDateInput = type => { - if (type !== this.state.activeDateInput) { - this.setState({ - activeDateInput: type, - }); - } - if (this.state.panel !== PANEL.DATE) { - this.setState({ - panel: PANEL.DATE, - }); - } - }; - - onFocusTimeInput = type => { - if (type !== this.state.activeDateInput) { - this.setState({ - activeDateInput: type, - }); - } - - if (this.state.panel !== PANEL.TIME) { - this.setState({ - panel: PANEL.TIME, - }); - } - }; - - onSelectStartTime = value => { - if (!('value' in this.props)) { - this.setState({ - startValue: value, - inputing: false, - activeDateInput: 'startTime', - }); - } - - if (value.valueOf() !== this.state.startValue.valueOf()) { - this.onValueChange([value, this.state.endValue]); - } - }; - - onSelectEndTime = value => { - if (!('value' in this.props)) { - this.setState({ - endValue: value, - inputing: false, - activeDateInput: 'endTime', - }); - } - if (value.valueOf() !== this.state.endValue.valueOf()) { - this.onValueChange([this.state.startValue, value]); - } - }; - - onTimeInputChange = inputStr => { - const stateName = mapInputStateName(this.state.activeDateInput); - this.setState({ - [stateName]: inputStr, - inputing: this.state.activeDateInput, - }); - }; - - onTimeInputBlur = () => { - const stateName = mapInputStateName(this.state.activeDateInput); - const timeInputStr = this.state[stateName]; - - const parsed = moment(timeInputStr, this.state.timeFormat, true); - - this.setState({ - [stateName]: '', - inputing: false, - }); - - if (parsed.isValid()) { - const hour = parsed.hour(); - const minute = parsed.minute(); - const second = parsed.second(); - const valueName = mapTimeToValue(this.state.activeDateInput); - const newValue = this.state[valueName] - .clone() - .hour(hour) - .minute(minute) - .second(second); - - this.handleChange(valueName, newValue); - } - }; - - onTimeInputKeyDown = e => { - const { showTime } = this.props; - const { activeDateInput, timeFormat } = this.state; - const stateName = mapInputStateName(activeDateInput); - const timeInputStr = this.state[stateName]; - const { disabledMinutes, disabledSeconds, hourStep = 1, minuteStep = 1, secondStep = 1 } = - typeof showTime === 'object' ? showTime : {}; - let unit = 'second'; - - if (disabledSeconds) { - unit = disabledMinutes ? 'hour' : 'minute'; - } - - const timeStr = onTimeKeydown( - e, - { - format: timeFormat, - timeInputStr, - value: this.state[activeDateInput.indexOf('start') ? 'startValue' : 'endValue'], - steps: { - hour: hourStep, - minute: minuteStep, - second: secondStep, - }, - }, - unit - ); - - if (!timeStr) return; - - this.onTimeInputChange(timeStr); - }; - - handleChange = (valueName, newValue) => { - const values = ['startValue', 'endValue'].map(name => (valueName === name ? newValue : this.state[name])); - - // 判断起始时间是否大于结束时间 - if (values[0] && values[1] && values[0].valueOf() > values[1].valueOf()) { - return; - } - - if (!('value' in this.props)) { - this.setState({ - [valueName]: newValue, - }); - } - - this.onValueChange(values); - }; - - onVisibleChange = (visible, type) => { - if (!('visible' in this.props)) { - this.setState({ - visible, - }); - } - this.props.onVisibleChange(visible, type); - }; - - changePanel = panel => { - const { startValue, endValue } = this.state; - this.setState({ - panel, - activeDateInput: - panel === PANEL.DATE ? (!!startValue && !endValue ? 'endValue' : 'startValue') : 'startTime', - }); - }; - - onOk = value => { - this.onVisibleChange(false, 'okBtnClick'); - this.onValueChange(value || [this.state.startValue, this.state.endValue], 'onOk'); - }; - - // 如果用户没有给定时间禁用逻辑,则给默认到禁用逻辑 - getDisabledTime = ({ startValue, endValue }) => { - const { disabledHours, disabledMinutes, disabledSeconds } = this.props.showTime || {}; - - let disabledTime = {}; - - if (startValue && endValue) { - const isSameDay = startValue.format('L') === endValue.format('L'); - const newDisabledHours = isFunction(disabledHours) - ? disabledHours - : index => { - if (isSameDay && index < startValue.hour()) { - return true; - } - }; - - const newDisabledMinutes = isFunction(disabledMinutes) - ? disabledMinutes - : index => { - if (isSameDay && startValue.hour() === endValue.hour() && index < startValue.minute()) { - return true; - } - }; - - const newDisabledSeconds = isFunction(disabledSeconds) - ? disabledSeconds - : index => { - if ( - isSameDay && - startValue.hour() === endValue.hour() && - startValue.minute() === endValue.minute() && - index < startValue.second() - ) { - return true; - } - }; - disabledTime = { - disabledHours: newDisabledHours, - disabledMinutes: newDisabledMinutes, - disabledSeconds: newDisabledSeconds, - }; - } - - return disabledTime; - }; - - renderPreview([startValue, endValue], others) { - const { prefix, className, renderPreview } = this.props; - const { dateTimeFormat } = this.state; - - const previewCls = classnames(className, `${prefix}form-preview`); - const startLabel = startValue ? startValue.format(dateTimeFormat) : ''; - const endLabel = endValue ? endValue.format(dateTimeFormat) : ''; - - if (typeof renderPreview === 'function') { - return ( -
    - {renderPreview([startValue, endValue], this.props)} -
    - ); - } - - return ( -

    - {startLabel} - {endLabel} -

    - ); - } - - render() { - const { - prefix, - rtl, - type, - defaultVisibleMonth, - onVisibleMonthChange, - showTime, - disabledDate, - footerRender, - label, - ranges = {}, // 兼容0.x ranges 属性 - state: inputState, - size, - disabled, - hasClear, - popupTriggerType, - popupAlign, - popupContainer, - popupStyle, - popupClassName, - popupProps, - popupComponent, - popupContent, - followTrigger, - className, - locale, - inputProps, - dateCellRender, - monthCellRender, - yearCellRender, - startDateInputAriaLabel, - startTimeInputAriaLabel, - endDateInputAriaLabel, - endTimeInputAriaLabel, - isPreview, - disableChangeMode, - yearRange, - placeholder, - ...others - } = this.props; - - const state = this.state; - - const classNames = classnames( - { - [`${prefix}range-picker`]: true, - [`${prefix}${size}`]: size, - [`${prefix}disabled`]: disabled, - }, - className - ); - - const panelBodyClassName = classnames({ - [`${prefix}range-picker-body`]: true, - [`${prefix}range-picker-body-show-time`]: showTime, - }); - - const triggerCls = classnames({ - [`${prefix}range-picker-trigger`]: true, - [`${prefix}error`]: inputState === 'error', - }); - - const startDateInputCls = classnames({ - [`${prefix}range-picker-panel-input-start-date`]: true, - [`${prefix}focus`]: state.activeDateInput === 'startValue', - }); - - const endDateInputCls = classnames({ - [`${prefix}range-picker-panel-input-end-date`]: true, - [`${prefix}focus`]: state.activeDateInput === 'endValue', - }); - - if (rtl) { - others.dir = 'rtl'; - } - - if (isPreview) { - return this.renderPreview( - [state.startValue, state.endValue], - obj.pickOthers(others, RangePicker.PropTypes) - ); - } - - const startDateInputValue = - state.inputing === 'startValue' - ? state.startDateInputStr - : (state.startValue && state.startValue.format(state.format)) || ''; - const endDateInputValue = - state.inputing === 'endValue' - ? state.endDateInputStr - : (state.endValue && state.endValue.format(state.format)) || ''; - - let startTriggerValue = startDateInputValue; - let endTriggerValue = endDateInputValue; - - const sharedInputProps = { - ...inputProps, - size, - disabled, - onChange: this.onDateInputChange, - onBlur: this.onDateInputBlur, - onPressEnter: this.onDateInputBlur, - onKeyDown: this.onDateInputKeyDown, - }; - - const startDateInput = ( - this.onFocusDateInput('startValue')} - className={startDateInputCls} - /> - ); - - const endDateInput = ( - this.onFocusDateInput('endValue')} - className={endDateInputCls} - /> - ); - - const shareCalendarProps = { - showOtherMonth: true, - dateCellRender: dateCellRender, - monthCellRender: monthCellRender, - yearCellRender: yearCellRender, - format: state.format, - defaultVisibleMonth: defaultVisibleMonth, - onVisibleMonthChange: onVisibleMonthChange, - }; - - const datePanel = - type === 'date' ? ( - - ) : ( -
    - { - return ( - (state.endValue && date.isAfter(state.endValue, type)) || - (disabledDate && disabledDate(date, ...args)) - ); - }} - onSelect={value => { - const selectedValue = value - .clone() - .date(1) - .hour(0) - .minute(0) - .second(0); - if (type === 'year') { - selectedValue.month(0); - } - this.onSelectCalendarPanel(selectedValue, 'startValue'); - }} - value={state.startValue} - /> - { - return ( - (state.startValue && date.isBefore(state.startValue, type)) || - (disabledDate && disabledDate(date, ...args)) - ); - }} - onSelect={value => { - const selectedValue = value - .clone() - .hour(23) - .minute(59) - .second(59); - if (type === 'year') { - selectedValue.month(11).date(31); - } else { - selectedValue.date(selectedValue.daysInMonth()); - } - this.onSelectCalendarPanel(selectedValue, 'endValue'); - }} - value={state.endValue} - /> -
    - ); - - let startTimeInput = null; - let endTimeInput = null; - let timePanel = null; - let panelFooter = footerRender(); - - if (showTime) { - const startTimeInputValue = - state.inputing === 'startTime' - ? state.startTimeInputStr - : (state.startValue && state.startValue.format(state.timeFormat)) || ''; - const endTimeInputValue = - state.inputing === 'endTime' - ? state.endTimeInputStr - : (state.endValue && state.endValue.format(state.timeFormat)) || ''; - - startTriggerValue = (state.startValue && state.startValue.format(state.dateTimeFormat)) || ''; - endTriggerValue = (state.endValue && state.endValue.format(state.dateTimeFormat)) || ''; - - const sharedTimeInputProps = { - size, - placeholder: state.timeFormat, - onFocus: this.onFocusTimeInput, - onBlur: this.onTimeInputBlur, - onPressEnter: this.onTimeInputBlur, - onChange: this.onTimeInputChange, - onKeyDown: this.onTimeInputKeyDown, - }; - - const startTimeInputCls = classnames({ - [`${prefix}range-picker-panel-input-start-time`]: true, - [`${prefix}focus`]: state.activeDateInput === 'startTime', - }); - - startTimeInput = ( - this.onFocusTimeInput('startTime')} - className={startTimeInputCls} - /> - ); - - const endTimeInputCls = classnames({ - [`${prefix}range-picker-panel-input-end-time`]: true, - [`${prefix}focus`]: state.activeDateInput === 'endTime', - }); - - endTimeInput = ( - this.onFocusTimeInput('endTime')} - className={endTimeInputCls} - /> - ); - - const showSecond = state.timeFormat.indexOf('s') > -1; - const showMinute = state.timeFormat.indexOf('m') > -1; - - const sharedTimePickerProps = { - ...showTime, - prefix, - locale, - disabled, - showSecond, - showMinute, - }; - - const disabledTime = this.getDisabledTime(state); - - timePanel = ( -
    - - -
    - ); - } - - panelFooter = panelFooter || ( - ({ - label: key, - value: ranges[key], - onChange: values => { - this.setState({ - startValue: values[0], - endValue: values[1], - }); - this.onValueChange(values); - }, - }))} - disabledOk={ - !state.startValue || !state.endValue || state.startValue.valueOf() > state.endValue.valueOf() - } - locale={locale} - panel={state.panel} - onPanelChange={showTime ? this.changePanel : null} - onOk={this.onOk} - /> - ); - - const panelBody = { - [PANEL.DATE]: datePanel, - [PANEL.TIME]: timePanel, - }[state.panel]; - - const allowClear = state.startValue && state.endValue && hasClear; - let [startPlaceholder, endPlaceholder] = placeholder || []; - - if (typeof placeholder === 'string') { - startPlaceholder = placeholder; - endPlaceholder = placeholder; - } - - const trigger = ( -
    - this.onFocusDateInput('startValue')} - /> - - - this.onFocusDateInput('endValue')} - hasClear={allowClear} - hint={} - /> -
    - ); - - const PopupComponent = popupComponent ? popupComponent : Popup; - - return ( -
    - - {popupContent ? ( - popupContent - ) : ( -
    -
    -
    - {startDateInput} - {startTimeInput} - - - {endDateInput} - {endTimeInput} -
    -
    - {panelBody} - {panelFooter} -
    - )} -
    -
    - ); - } -} - -export default polyfill(RangePicker); diff --git a/components/date-picker/range-picker.tsx b/components/date-picker/range-picker.tsx new file mode 100644 index 0000000000..59d1c72d73 --- /dev/null +++ b/components/date-picker/range-picker.tsx @@ -0,0 +1,1112 @@ +import React, { + Component, + createRef, + type HTMLAttributes, + type KeyboardEvent, + type SyntheticEvent, +} from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import ConfigProvider from '../config-provider'; +import Overlay from '../overlay'; +import Input from '../input'; +import Icon from '../icon'; +import Calendar from '../calendar'; +import RangeCalendar from '../calendar/range-calendar'; +import TimePickerPanel from '../time-picker/panel'; +import nextLocale from '../locale/zh-cn'; +import { type ClassPropsWithDefault, func, obj } from '../util'; +import { + PANEL, + resetValueTime, + formatDateValue, + getDateTimeFormat, + isFunction, + onDateKeydown, + onTimeKeydown, +} from './util'; +import PanelFooter from './module/panel-footer'; +import type { PanelType, RangePickerProps, RangePickerState } from './types'; +import { type TimePickerProps } from '../time-picker'; + +const { Popup } = Overlay; + +function mapInputStateName(name: string) { + return ( + { + startValue: 'startDateInputStr', + endValue: 'endDateInputStr', + startTime: 'startTimeInputStr', + endTime: 'endTimeInputStr', + } as const + )[name]; +} + +function mapTimeToValue(name: string) { + return ( + { + startTime: 'startValue', + endTime: 'endValue', + } as const + )[name]; +} + +function getFormatValues(values: RangePickerProps['value'] | null, format?: string) { + if (!Array.isArray(values)) { + return [null, null]; + } + return [formatDateValue(values[0], format), formatDateValue(values[1], format)]; +} + +type InnerRangePickerProps = ClassPropsWithDefault< + RangePickerProps, + typeof RangePicker.defaultProps +>; + +/** + * DatePicker.RangePicker + */ +class RangePicker extends Component { + static displayName = 'RangePicker'; + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + type: PropTypes.oneOf(['date', 'month', 'year']), + defaultVisibleMonth: PropTypes.func, + onVisibleMonthChange: PropTypes.func, + value: PropTypes.array, + defaultValue: PropTypes.array, + format: PropTypes.string, + showTime: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + resetTime: PropTypes.bool, + disabledDate: PropTypes.func, + footerRender: PropTypes.func, + onChange: PropTypes.func, + onOk: PropTypes.func, + label: PropTypes.node, + state: PropTypes.oneOf(['error', 'loading', 'success']), + size: PropTypes.oneOf(['small', 'medium', 'large']), + disabled: PropTypes.bool, + hasClear: PropTypes.bool, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + popupTriggerType: PropTypes.oneOf(['click', 'hover']), + popupAlign: PropTypes.string, + popupContainer: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + inputProps: PropTypes.object, + dateCellRender: PropTypes.func, + monthCellRender: PropTypes.func, + yearCellRender: PropTypes.func, + startDateInputAriaLabel: PropTypes.string, + startTimeInputAriaLabel: PropTypes.string, + endDateInputAriaLabel: PropTypes.string, + endTimeInputAriaLabel: PropTypes.string, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + disableChangeMode: PropTypes.bool, + yearRange: PropTypes.arrayOf(PropTypes.number), + ranges: PropTypes.object, + locale: PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + popupComponent: PropTypes.elementType, + popupContent: PropTypes.node, + placeholder: PropTypes.oneOfType([PropTypes.arrayOf(PropTypes.string), PropTypes.string]), + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + type: 'date', + size: 'medium', + showTime: false, + resetTime: false, + disabledDate: () => false, + footerRender: () => null, + hasClear: true, + defaultVisible: false, + popupTriggerType: 'click', + popupAlign: 'tl tl', + locale: nextLocale.DatePicker, + disableChangeMode: false, + onChange: func.noop, + onOk: func.noop, + onVisibleChange: func.noop, + }; + + readonly props: InnerRangePickerProps; + + startDateInputRef = createRef>(); + endDateInputRef = createRef>(); + autoSwitchDateInput = false; + + constructor(props: RangePickerProps) { + super(props); + const { format, timeFormat, dateTimeFormat } = getDateTimeFormat( + props.format, + props.showTime, + props.type + ); + + const val = props.value || props.defaultValue; + const values = getFormatValues(val, dateTimeFormat); + + this.state = { + visible: props.visible || props.defaultVisible, + startValue: values[0], + endValue: values[1], + startDateInputStr: '', + endDateInputStr: '', + activeDateInput: 'startValue', + startTimeInputStr: '', + endTimeInputStr: '', + inputing: false, // 当前是否处于输入状态 + panel: PANEL.DATE, + format, + timeFormat, + dateTimeFormat, + inputAsString: val && (typeof val[0] === 'string' || typeof val[1] === 'string'), + }; + } + static getDerivedStateFromProps(props: InnerRangePickerProps) { + const formatStates = getDateTimeFormat(props.format, props.showTime, props.type); + const states: Partial = {}; + + if ('value' in props) { + const values = getFormatValues(props.value, formatStates.dateTimeFormat); + states.startValue = values[0]; + states.endValue = values[1]; + if ( + props.value && + (typeof props.value[0] === 'string' || typeof props.value[1] === 'string') + ) { + states.inputAsString = true; + } + if ( + props.value && + (moment.isMoment(props.value[0]) || moment.isMoment(props.value[1])) + ) { + states.inputAsString = false; + } + } + + if ('visible' in props) { + states.visible = props.visible; + } + + return { + ...states, + ...formatStates, + }; + } + + onValueChange = ( + values: (Moment | undefined | null)[], + handler: 'onOk' | 'onChange' = 'onChange' + ) => { + let ret: (Moment | string | undefined | null)[]; + if (!values.length || !this.state.inputAsString) { + ret = values; + } else { + ret = [ + values[0] ? values[0].format(this.state.dateTimeFormat) : null, + values[1] ? values[1].format(this.state.dateTimeFormat) : null, + ]; + } + this.props[handler](ret); + }; + + onSelectCalendarPanel = (value: Moment, active?: RangePickerState['activeDateInput']) => { + const { showTime, resetTime } = this.props; + const { + activeDateInput: prevActiveDateInput, + startValue: prevStartValue, + endValue: prevEndValue, + timeFormat, + } = this.state; + + const newState: Partial = { + activeDateInput: active || prevActiveDateInput, + inputing: false, + }; + + let newValue = value; + + switch (active || prevActiveDateInput) { + case 'startValue': { + if (!prevEndValue || this.autoSwitchDateInput) { + newState.activeDateInput = 'endValue'; + } + + if (showTime) { + if (!prevStartValue) { + // 第一次选择,如果设置了时间默认值,则使用该默认时间 + if (typeof showTime === 'object' && showTime.defaultValue) { + const defaultTimeValue = formatDateValue( + Array.isArray(showTime.defaultValue) + ? showTime.defaultValue[0] + : showTime.defaultValue, + timeFormat + ); + newValue = resetValueTime(value, defaultTimeValue); + } + } else if (!resetTime) { + // 非第一次选择,如果开启了 resetTime,则记住之前选择的时间值 + newValue = resetValueTime(value, prevStartValue); + } + } + + newState.startValue = newValue; + + // 如果起始日期大于结束日期 + if (prevEndValue && newValue.valueOf() > prevEndValue.valueOf()) { + // 将结束日期设置为起始日期 如果需要的话保留时间 + newState.endValue = resetTime ? newValue : resetValueTime(value, prevEndValue); + + // 如果结束日期不大于起始日期则将结束日期设置为等于开始日期 + if (newState.endValue.valueOf() < newState.startValue.valueOf()) { + newState.endValue = moment(newState.startValue); + } + newState.activeDateInput = 'endValue'; + } + break; + } + + case 'endValue': + if (!prevStartValue || this.autoSwitchDateInput) { + newState.activeDateInput = 'startValue'; + } + + if (showTime) { + if (!prevEndValue) { + // 第一次选择,如果设置了时间默认值,则使用该默认时间 + if (typeof showTime === 'object' && showTime.defaultValue) { + const defaultTimeValue = formatDateValue( + Array.isArray(showTime.defaultValue) + ? showTime.defaultValue[1] || showTime.defaultValue[0] + : showTime.defaultValue, + timeFormat + ); + newValue = resetValueTime(value, defaultTimeValue); + } + } else if (!resetTime) { + // 非第一次选择,如果开启了 resetTime ,则记住之前选择的时间值 + newValue = resetValueTime(value, prevEndValue); + } + } + + newState.endValue = newValue; + + // 选择了一个比开始日期更小的结束日期,此时表示用户重新选择了 + if (prevStartValue && newValue.valueOf() <= prevStartValue.valueOf()) { + newState.startValue = resetTime ? value : resetValueTime(value, prevStartValue); + newState.endValue = resetTime ? value : resetValueTime(value, prevEndValue); + + // 如果结束日期不大于起始日期则将结束日期设置为等于开始日期 + if (newState.endValue.valueOf() < newState.startValue.valueOf()) { + newState.endValue = moment(newState.startValue); + } + } + break; + } + + const newStartValue = 'startValue' in newState ? newState.startValue : prevStartValue; + const newEndValue = 'endValue' in newState ? newState.endValue : prevEndValue; + + // 每当 input 发生了自动切换,则关闭自动切换 + if (newState.activeDateInput !== prevActiveDateInput) { + this.autoSwitchDateInput = false; + } + + // 受控状态选择不更新值 + if ('value' in this.props) { + delete newState.startValue; + delete newState.endValue; + } + + this.setState(newState as RangePickerState); + + this.onValueChange([newStartValue, newEndValue]); + }; + + clearRange = () => { + this.setState({ + startDateInputStr: '', + endDateInputStr: '', + startTimeInputStr: '', + endTimeInputStr: '', + }); + + if (!('value' in this.props)) { + this.setState({ + startValue: null, + endValue: null, + }); + } + + this.onValueChange([]); + }; + + onDateInputChange = (inputStr: string, e: SyntheticEvent, eventType?: string) => { + if (eventType === 'clear' || !inputStr) { + e.stopPropagation(); + this.clearRange(); + } else { + const stateName = mapInputStateName(this.state.activeDateInput!); + this.setState({ + [stateName!]: inputStr, + inputing: this.state.activeDateInput, + }); + } + }; + + onDateInputBlur = () => { + const { resetTime } = this.props; + const { activeDateInput } = this.state; + const stateName = mapInputStateName(activeDateInput!); + const dateInputStr = this.state[stateName!]; + + if (dateInputStr) { + const { format, disabledDate } = this.props; + const parsed = moment(dateInputStr, format, true); + + this.setState({ + [stateName!]: '', + inputing: false, + }); + + if (parsed.isValid() && !disabledDate(parsed, 'date')) { + const valueName = activeDateInput as 'startValue' | 'endValue'; + const newValue = resetTime + ? parsed + : resetValueTime(parsed, this.state[activeDateInput!]); + + this.handleChange(valueName, newValue); + } + } + }; + + onDateInputKeyDown = (e: KeyboardEvent) => { + const { type } = this.props; + const { activeDateInput, format } = this.state; + const stateName = mapInputStateName(activeDateInput!); + const dateInputStr = this.state[stateName!]; + const dateStr = onDateKeydown( + e, + { + format, + value: this.state[activeDateInput!], + dateInputStr: dateInputStr!, + }, + type === 'date' ? 'day' : type + ); + if (!dateStr) return; + + // @ts-expect-error 应该传入 e + return this.onDateInputChange(dateStr); + }; + + onFocusDateInput = (type: RangePickerState['activeDateInput']) => { + if (type !== this.state.activeDateInput) { + this.setState({ + activeDateInput: type, + }); + } + if (this.state.panel !== PANEL.DATE) { + this.setState({ + panel: PANEL.DATE, + }); + } + }; + + onFocusTimeInput = (type: RangePickerState['activeDateInput']) => { + if (type !== this.state.activeDateInput) { + this.setState({ + activeDateInput: type, + }); + } + + if (this.state.panel !== PANEL.TIME) { + this.setState({ + panel: PANEL.TIME, + }); + } + }; + + onSelectStartTime = (value: Moment) => { + if (!('value' in this.props)) { + this.setState({ + startValue: value, + inputing: false, + activeDateInput: 'startTime', + }); + } + // @ts-expect-error 未考虑 startValue 为 null 的情况 + if (value.valueOf() !== this.state.startValue.valueOf()) { + this.onValueChange([value, this.state.endValue]); + } + }; + + onSelectEndTime = (value: Moment) => { + if (!('value' in this.props)) { + this.setState({ + endValue: value, + inputing: false, + activeDateInput: 'endTime', + }); + } + // @ts-expect-error 未考虑 endValue 为 null 的情况 + if (value.valueOf() !== this.state.endValue.valueOf()) { + this.onValueChange([this.state.startValue, value]); + } + }; + + onTimeInputChange = (inputStr: string) => { + const stateName = mapInputStateName(this.state.activeDateInput!); + this.setState({ + [stateName!]: inputStr, + inputing: this.state.activeDateInput, + }); + }; + + onTimeInputBlur = () => { + const stateName = mapInputStateName(this.state.activeDateInput!); + const timeInputStr = this.state[stateName!]; + + const parsed = moment(timeInputStr, this.state.timeFormat, true); + + this.setState({ + [stateName!]: '', + inputing: false, + }); + + if (parsed.isValid()) { + const hour = parsed.hour(); + const minute = parsed.minute(); + const second = parsed.second(); + const valueName = mapTimeToValue(this.state.activeDateInput!); + // @ts-expect-error 未考虑 startValue 为 null 的情况 + const newValue = this.state[valueName!] + .clone() + .hour(hour) + .minute(minute) + .second(second); + + this.handleChange(valueName!, newValue); + } + }; + + onTimeInputKeyDown = (e: KeyboardEvent) => { + const { showTime } = this.props; + const { activeDateInput, timeFormat } = this.state; + const stateName = mapInputStateName(activeDateInput!); + const timeInputStr = this.state[stateName!]; + const { + disabledMinutes, + disabledSeconds, + hourStep = 1, + minuteStep = 1, + secondStep = 1, + } = typeof showTime === 'object' ? showTime : ({} as TimePickerProps); + let unit: 'hour' | 'minute' | 'second' = 'second'; + + if (disabledSeconds) { + unit = disabledMinutes ? 'hour' : 'minute'; + } + + const timeStr = onTimeKeydown( + e, + { + format: timeFormat!, + timeInputStr: timeInputStr!, + value: this.state[activeDateInput!.indexOf('start') ? 'startValue' : 'endValue'], + steps: { + hour: hourStep, + minute: minuteStep, + second: secondStep, + }, + }, + unit + ); + + if (!timeStr) return; + + this.onTimeInputChange(timeStr); + }; + + handleChange = (valueName: 'startValue' | 'endValue', newValue?: Moment | null) => { + const values = (['startValue', 'endValue'] as const).map(name => + valueName === name ? newValue : this.state[name] + ) as [start?: Moment | null, end?: Moment | null]; + + // 判断起始时间是否大于结束时间 + if (values[0] && values[1] && values[0].valueOf() > values[1].valueOf()) { + return; + } + + if (!('value' in this.props)) { + this.setState({ + [valueName]: newValue, + }); + } + + this.onValueChange(values); + }; + + onVisibleChange = (visible: boolean, type: string) => { + if (!('visible' in this.props)) { + this.setState({ + visible, + }); + } + this.props.onVisibleChange(visible, type); + }; + + changePanel = (panel: PanelType) => { + const { startValue, endValue } = this.state; + this.setState({ + panel, + activeDateInput: + panel === PANEL.DATE + ? !!startValue && !endValue + ? 'endValue' + : 'startValue' + : 'startTime', + }); + }; + + onOk = (value?: (Moment | null | undefined)[]) => { + this.onVisibleChange(false, 'okBtnClick'); + this.onValueChange(value || [this.state.startValue, this.state.endValue], 'onOk'); + }; + + // 如果用户没有给定时间禁用逻辑,则给默认到禁用逻辑 + getDisabledTime = ({ + startValue, + endValue, + }: { startValue?: Moment | null; endValue?: Moment | null } & Record) => { + const { disabledHours, disabledMinutes, disabledSeconds } = (this.props.showTime || + {}) as TimePickerProps; + + let disabledTime = {}; + + if (startValue && endValue) { + const isSameDay = startValue.format('L') === endValue.format('L'); + const newDisabledHours = isFunction(disabledHours) + ? disabledHours + : (index: number) => { + if (isSameDay && index < startValue.hour()) { + return true; + } + }; + + const newDisabledMinutes = isFunction(disabledMinutes) + ? disabledMinutes + : (index: number) => { + if ( + isSameDay && + startValue.hour() === endValue.hour() && + index < startValue.minute() + ) { + return true; + } + }; + + const newDisabledSeconds = isFunction(disabledSeconds) + ? disabledSeconds + : (index: number) => { + if ( + isSameDay && + startValue.hour() === endValue.hour() && + startValue.minute() === endValue.minute() && + index < startValue.second() + ) { + return true; + } + }; + disabledTime = { + disabledHours: newDisabledHours, + disabledMinutes: newDisabledMinutes, + disabledSeconds: newDisabledSeconds, + }; + } + + return disabledTime; + }; + + enableAutoSwitchDateInput = () => { + this.autoSwitchDateInput = true; + }; + + afterOpen = () => { + // autoFocus 逻辑手动处理 + switch (this.state.activeDateInput) { + case 'startValue': { + if (this.startDateInputRef.current) { + this.startDateInputRef.current.getInstance().focus(); + } + break; + } + case 'endValue': { + if (this.endDateInputRef.current) { + this.endDateInputRef.current.getInstance().focus(); + } + break; + } + } + }; + + renderPreview( + [startValue, endValue]: [Moment | null, Moment | null], + others: HTMLAttributes + ) { + const { prefix, className, renderPreview } = this.props; + const { dateTimeFormat } = this.state; + + const previewCls = classnames(className, `${prefix}form-preview`); + const startLabel = startValue ? startValue.format(dateTimeFormat) : ''; + const endLabel = endValue ? endValue.format(dateTimeFormat) : ''; + + if (typeof renderPreview === 'function') { + return ( +
    + {renderPreview([startValue, endValue], this.props)} +
    + ); + } + + return ( +

    + {startLabel} - {endLabel} +

    + ); + } + + render() { + const { + prefix, + rtl, + type, + defaultVisibleMonth, + onVisibleMonthChange, + showTime, + disabledDate, + footerRender, + label, + ranges = {}, // 兼容 0.x ranges 属性 + state: inputState, + size, + disabled, + hasClear, + popupTriggerType, + popupAlign, + popupContainer, + popupStyle, + popupClassName, + popupProps, + popupComponent, + popupContent, + followTrigger, + className, + locale, + inputProps, + dateCellRender, + monthCellRender, + yearCellRender, + startDateInputAriaLabel, + startTimeInputAriaLabel, + endDateInputAriaLabel, + endTimeInputAriaLabel, + isPreview, + disableChangeMode, + yearRange, + placeholder, + ...others + } = this.props; + + const state = this.state; + + const classNames = classnames( + { + [`${prefix}range-picker`]: true, + [`${prefix}${size}`]: size, + [`${prefix}disabled`]: disabled, + }, + className + ); + + const panelBodyClassName = classnames({ + [`${prefix}range-picker-body`]: true, + [`${prefix}range-picker-body-show-time`]: showTime, + }); + + const triggerCls = classnames({ + [`${prefix}range-picker-trigger`]: true, + [`${prefix}error`]: inputState === 'error', + }); + + const startDateInputCls = classnames({ + [`${prefix}range-picker-panel-input-start-date`]: true, + [`${prefix}focus`]: state.activeDateInput === 'startValue', + }); + + const endDateInputCls = classnames({ + [`${prefix}range-picker-panel-input-end-date`]: true, + [`${prefix}focus`]: state.activeDateInput === 'endValue', + }); + + if (rtl) { + others.dir = 'rtl'; + } + + if (isPreview) { + return this.renderPreview( + [state.startValue!, state.endValue!], + // @ts-expect-error 应为 propTypes + obj.pickOthers(others, RangePicker.PropTypes) + ); + } + + const startDateInputValue = + state.inputing === 'startValue' + ? state.startDateInputStr + : (state.startValue && state.startValue.format(state.format)) || ''; + const endDateInputValue = + state.inputing === 'endValue' + ? state.endDateInputStr + : (state.endValue && state.endValue.format(state.format)) || ''; + + let startTriggerValue = startDateInputValue; + let endTriggerValue = endDateInputValue; + + const sharedInputProps = { + ...inputProps, + size, + disabled, + onChange: this.onDateInputChange, + onBlur: this.onDateInputBlur, + onPressEnter: this.onDateInputBlur, + onKeyDown: this.onDateInputKeyDown, + }; + + const startDateInput = ( + this.onFocusDateInput('startValue')} + className={startDateInputCls} + ref={this.startDateInputRef} + onClick={func.makeChain(this.enableAutoSwitchDateInput, sharedInputProps.onClick)} + /> + ); + + const endDateInput = ( + this.onFocusDateInput('endValue')} + className={endDateInputCls} + ref={this.endDateInputRef} + onClick={func.makeChain(this.enableAutoSwitchDateInput, sharedInputProps.onClick)} + /> + ); + + const shareCalendarProps = { + showOtherMonth: true, + dateCellRender: dateCellRender, + monthCellRender: monthCellRender, + yearCellRender: yearCellRender, + format: state.format, + defaultVisibleMonth: defaultVisibleMonth, + onVisibleMonthChange: onVisibleMonthChange, + }; + + const datePanel = + type === 'date' ? ( + + ) : ( +
    + { + return ( + (state.endValue && date.isAfter(state.endValue, type)) || + (disabledDate && disabledDate(date, ...args)) + ); + }} + onSelect={value => { + const selectedValue = value.clone().date(1).hour(0).minute(0).second(0); + if (type === 'year') { + selectedValue.month(0); + } + this.onSelectCalendarPanel(selectedValue, 'startValue'); + }} + value={state.startValue} + /> + { + return ( + (state.startValue && date.isBefore(state.startValue, type)) || + (disabledDate && disabledDate(date, ...args)) + ); + }} + onSelect={value => { + const selectedValue = value.clone().hour(23).minute(59).second(59); + if (type === 'year') { + selectedValue.month(11).date(31); + } else { + selectedValue.date(selectedValue.daysInMonth()); + } + this.onSelectCalendarPanel(selectedValue, 'endValue'); + }} + value={state.endValue} + /> +
    + ); + + let startTimeInput = null; + let endTimeInput = null; + let timePanel = null; + let panelFooter = footerRender(); + + if (showTime) { + const startTimeInputValue = + state.inputing === 'startTime' + ? state.startTimeInputStr + : (state.startValue && state.startValue.format(state.timeFormat)) || ''; + const endTimeInputValue = + state.inputing === 'endTime' + ? state.endTimeInputStr + : (state.endValue && state.endValue.format(state.timeFormat)) || ''; + + startTriggerValue = + (state.startValue && state.startValue.format(state.dateTimeFormat)) || ''; + endTriggerValue = (state.endValue && state.endValue.format(state.dateTimeFormat)) || ''; + + const sharedTimeInputProps = { + size, + placeholder: state.timeFormat, + onFocus: this.onFocusTimeInput, + onBlur: this.onTimeInputBlur, + onPressEnter: this.onTimeInputBlur, + onChange: this.onTimeInputChange, + onKeyDown: this.onTimeInputKeyDown, + }; + + const startTimeInputCls = classnames({ + [`${prefix}range-picker-panel-input-start-time`]: true, + [`${prefix}focus`]: state.activeDateInput === 'startTime', + }); + + startTimeInput = ( + this.onFocusTimeInput('startTime')} + className={startTimeInputCls} + /> + ); + + const endTimeInputCls = classnames({ + [`${prefix}range-picker-panel-input-end-time`]: true, + [`${prefix}focus`]: state.activeDateInput === 'endTime', + }); + + endTimeInput = ( + this.onFocusTimeInput('endTime')} + className={endTimeInputCls} + /> + ); + + const showSecond = state.timeFormat!.indexOf('s') > -1; + const showMinute = state.timeFormat!.indexOf('m') > -1; + + const sharedTimePickerProps = { + ...(showTime as TimePickerProps), + prefix, + locale, + disabled, + showSecond, + showMinute, + }; + + const disabledTime = this.getDisabledTime(state); + + timePanel = ( +
    + + +
    + ); + } + + panelFooter = panelFooter || ( + + prefix={prefix} + value={state.startValue || state.endValue} + ranges={Object.keys(ranges).map(key => ({ + label: key, + value: ranges[key], + onChange: (values: [Moment, Moment]) => { + this.setState({ + startValue: values[0], + endValue: values[1], + }); + this.onValueChange(values); + }, + }))} + disabledOk={ + !state.startValue || + !state.endValue || + state.startValue.valueOf() > state.endValue.valueOf() + } + locale={locale} + panel={state.panel!} + onPanelChange={showTime ? this.changePanel : null} + onOk={this.onOk} + /> + ); + + const panelBody = { + [PANEL.DATE]: datePanel, + [PANEL.TIME]: timePanel, + }[state.panel!]; + + const allowClear = (state.startValue || state.endValue) && hasClear; + let [startPlaceholder, endPlaceholder] = placeholder || []; + + if (typeof placeholder === 'string') { + startPlaceholder = placeholder; + endPlaceholder = placeholder; + } + + const trigger = ( +
    + this.onFocusDateInput('startValue')} + /> + - + this.onFocusDateInput('endValue')} + // @ts-expect-error allowClear 应先进行 boolean 转换 + hasClear={allowClear} + hint={ + + } + /> +
    + ); + + const PopupComponent = popupComponent ? popupComponent : Popup; + + return ( +
    + + {popupContent ? ( + popupContent + ) : ( +
    +
    +
    + {startDateInput} + {startTimeInput} + + - + + {endDateInput} + {endTimeInput} +
    +
    + {panelBody} + {panelFooter} +
    + )} +
    +
    + ); + } +} + +export default polyfill(RangePicker); diff --git a/components/date-picker/style.js b/components/date-picker/style.ts similarity index 100% rename from components/date-picker/style.js rename to components/date-picker/style.ts diff --git a/components/date-picker/types.ts b/components/date-picker/types.ts new file mode 100644 index 0000000000..2b8ef371b2 --- /dev/null +++ b/components/date-picker/types.ts @@ -0,0 +1,1232 @@ +import type { Moment, MomentInput } from 'moment'; +import type React from 'react'; +import type { CommonProps } from '../util'; +import type { PopupProps } from '../overlay'; +import type { InputProps } from '../input'; +import type { TimePickerProps } from '../time-picker'; +import type { Locale } from '../locale/types'; +import type { RangeCalendarProps } from '../calendar'; + +export type PanelType = 'time-panel' | 'date-panel'; + +/** + * @api DatePicker + * @order 1 + */ +export interface DatePickerProps + extends Omit, 'onChange' | 'defaultValue'>, + CommonProps, + DeprecatedProps { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 输入框内置标签 + * @en Inset label of input + */ + label?: React.ReactNode; + + /** + * 输入框状态 + * @en Input status + */ + state?: 'success' | 'loading' | 'error'; + + /** + * 输入提示 + * @en Placeholder + */ + placeholder?: string; + + /** + * 默认展现的月 + * @en Default displayed month + */ + defaultVisibleMonth?: () => Moment; + + /** + * 默认展现的年 + * @en Default displayed year + */ + defaultVisibleYear?: () => Moment; + + /** + * 日期值(受控)moment 对象 + * @en Date value, moment object, controlled + */ + value?: string | Moment | null; + + /** + * 初始日期值,moment 对象 + * @en Initial date value, moment object, uncontrolled + */ + defaultValue?: string | Moment | null; + + /** + * 日期值的格式(用于限定用户输入和展示) + * @en Date value format(for restricting user input and display) + * @defaultValue 'YYYY-MM-DD' + */ + format?: string; + + /** + * 是否使用时间控件,传入 TimePicker 的属性 \{ defaultValue, format, ... \} + * @en Whether to use time control, pass the props of TimePicker \{ defaultValue, format, ... \} + * @defaultValue false + */ + showTime?: TimePickerProps | boolean; + + /** + * 每次选择日期时是否重置时间(仅在 showTime 开启时有效) + * @en Whether to reset time when selecting a date(only valid when showTime is enabled) + * @defaultValue false + */ + resetTime?: boolean; + + /** + * 禁用日期函数 + * @en Disable date function + */ + disabledDate?: (date: Moment, view: string) => boolean; + + /** + * 自定义面板页脚 + * @en Custom panel footer + */ + footerRender?: () => React.ReactNode; + + /** + * 日期值改变时的回调 + * @en Callback when the date value changes + */ + onChange?: (value: string | Moment | null) => void; + + /** + * 点击确认按钮时的回调 + * @en Callback when the confirm button is clicked + */ + onOk?: (value: string | Moment | null) => void; + + /** + * 输入框尺寸 + * @en Input box size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + + /** + * 是否禁用 + * @en Whether to disable + */ + disabled?: boolean; + + /** + * 是否显示清空按钮 + * @en Whether to display the clear button + * @defaultValue true + */ + hasClear?: boolean; + + /** + * 弹层显示状态 + * @en Pop-up display status + */ + visible?: boolean; + + /** + * 弹层默认是否显示 + * @en Pop-up default display status, uncontrolled + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 弹层展示状态变化时的回调 + * @en Callback when the pop-up display status changes + */ + onVisibleChange?: (visible: boolean, reason: string) => void; + + /** + * 弹层展示月份变化时的回调 + * @en Callback when the pop-up display month changes + */ + onVisibleMonthChange?: (value: Moment, reason: string) => void; + + /** + * 弹层触发方式 + * @en Pop-up trigger + * @defaultValue 'click' + */ + popupTriggerType?: 'click' | 'hover'; + + /** + * 弹层对齐方式,具体含义见 OverLay 文档 + * @en Pop-up alignment, see OverLay documentation + * @defaultValue 'tl tl' + */ + popupAlign?: string; + + /** + * 弹层容器 + * @en Pop-up container + */ + popupContainer?: PopupProps['container']; + + /** + * 弹层自定义样式 + * @en Pop-up custom style + */ + popupStyle?: React.CSSProperties; + + /** + * 弹层自定义样式类 + * @en Pop-up custom style class + */ + popupClassName?: string; + + /** + * 弹层其他属性 + * @en Pop-up other attributes + */ + popupProps?: React.PropsWithRef; + + /** + * 输入框其他属性 + * @en Input box other attributes + */ + inputProps?: InputProps; + + /** + * 自定义日期渲染函数 + * @en Custom date rendering function + */ + dateCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 自定义月份渲染函数 + * @en Custom month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 日期输入框的 aria-label 属性 + * @en Date input box aria-label + */ + dateInputAriaLabel?: string; + + /** + * 时间输入框的 aria-label 属性 + * @en Time input box aria-label + */ + timeInputAriaLabel?: string; + + /** + * 是否为预览态 + * @en Whether it is a preview state + */ + isPreview?: boolean; + + /** + * 预览态定制渲染函数 + * @en Preview state custom rendering function + */ + renderPreview?: (value: Moment | null, props: DatePickerProps) => React.ReactNode; + + /** + * 是否跟随滚动 + * @en Whether Pop-up follows trigger when scrolling + */ + followTrigger?: boolean; + + /** + * 自定义弹层 + * @en Custom pop-up + */ + popupComponent?: React.ComponentType; + + /** + * 自定义弹层内容 + * @en Custom pop-up content + */ + popupContent?: React.ReactElement; + + /** + * 禁用日期选择器的日期模式切换 + * @en Disable date selection + */ + disableChangeMode?: boolean; + /** + * 年份范围,[START_YEAR, END_YEAR] + * @en Year range, [START_YEAR, END_YEAR] + */ + yearRange?: [start: number, end: number]; + /** + * @skip + */ + locale?: Locale['DatePicker']; +} + +export interface DatePickerState { + value: Moment | null; + inputAsString: boolean; + dateInputStr: string; + timeInputStr: string; + inputing: false | 'date' | 'time'; + visible: boolean; + panel: PanelType; + format: string; + timeFormat: string; + dateTimeFormat: string; + // FIXME 不加入的话会导致 setState 时有比较难解的类型问题 + [key: string]: unknown; +} + +/** + * @api DatePicker.MonthPicker + * @order 2 + */ +export interface MonthPickerProps + extends Omit, 'onChange' | 'defaultValue'>, + CommonProps, + DeprecatedProps { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 输入框内置标签 + * @en Inset label of input + */ + label?: React.ReactNode; + + /** + * 输入框状态 + * @en Input status + */ + state?: 'success' | 'loading' | 'error'; + + /** + * 输入提示 + * @en Placeholder + */ + placeholder?: string; + + /** + * 默认展现的年 + * @en Default displayed year + */ + defaultVisibleYear?: () => Moment | null; + + /** + * 日期值(受控)moment 对象 + * @en Date value, moment object, controlled + */ + value?: string | Moment | null; + + /** + * 初始日期值,moment 对象 + * @en Initial date value, moment object, uncontrolled + */ + defaultValue?: string | Moment | null; + + /** + * 日期值的格式(用于限定用户输入和展示) + * @en Date value format(for restricting user input and display) + * @defaultValue 'YYYY-MM' + */ + format?: string; + + /** + * 禁用日期函数 + * @en Disable date function + */ + disabledDate?: (date: Moment, view: string) => boolean; + + /** + * 自定义面板页脚 + * @en Custom panel footer + */ + footerRender?: () => React.ReactNode; + + /** + * 日期值改变时的回调 + * @en Callback when the date value changes + */ + onChange?: (value: Moment | string | null) => void; + + /** + * 输入框尺寸 + * @en Input box size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + + /** + * 是否禁用 + * @en Whether to disable + */ + disabled?: boolean; + + /** + * 是否显示清空按钮 + * @en Whether to display the clear button + * @defaultValue true + */ + hasClear?: boolean; + + /** + * 弹层显示状态 + * @en Pop-up display status + */ + visible?: boolean; + + /** + * 弹层默认是否显示 + * @en Pop-up default display status, uncontrolled + */ + defaultVisible?: boolean; + + /** + * 弹层展示状态变化时的回调 + * @en Callback when the pop-up display status changes + */ + onVisibleChange?: (visible: boolean, reason: string) => void; + + /** + * 弹层触发方式 + * @en Pop-up trigger + * @defaultValue 'click' + */ + popupTriggerType?: 'click' | 'hover'; + + /** + * 弹层对齐方式,具体含义见 OverLay 文档 + * @en Pop-up alignment, see OverLay documentation + * @defaultValue 'tl tl' + */ + popupAlign?: string; + + /** + * 弹层容器 + * @en Pop-up container + */ + popupContainer?: PopupProps['container']; + + /** + * 弹层自定义样式 + * @en Pop-up custom style + */ + popupStyle?: React.CSSProperties; + + /** + * 弹层自定义样式类 + * @en Pop-up custom style class + */ + popupClassName?: string; + + /** + * 弹层其他属性 + * @en Pop-up other attributes + */ + popupProps?: React.PropsWithRef; + + /** + * 自定义弹层 + * @en Custom pop-up + */ + popupComponent?: React.ComponentType; + + /** + * 自定义弹层内容 + * @en Custom pop-up content + */ + popupContent?: React.ReactElement; + + /** + * 是否跟随滚动 + * @en Whether Pop-up follows trigger when scrolling + */ + followTrigger?: boolean; + + /** + * 输入框其他属性 + * @en Input box other attributes + */ + inputProps?: InputProps; + + /** + * 自定义月份渲染函数 + * @en Custom month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 日期输入框的 aria-label 属性 + * @en Date input box aria-label + */ + dateInputAriaLabel?: string; + /** + * 预览态定制渲染函数 + * @en Preview state custom rendering function + */ + renderPreview?: (value: Moment | null, props: MonthPickerProps) => void; + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 是否为预览态 + * @en Whether it is a preview state + */ + isPreview?: boolean; + /** + * @skip + */ + locale?: Locale['DatePicker']; +} + +export interface MonthPickerState { + value: Moment | null; + inputAsString: boolean; + visible: boolean | undefined; + dateInputStr: string; + inputing: boolean; +} + +/** + * @api DatePicker.RangePicker + * @order 3 + */ +export interface RangePickerProps + extends Omit, 'onChange' | 'defaultValue' | 'placeholder'>, + CommonProps, + DeprecatedProps { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + + /** + * 日期范围类型 + * @en Date range type + */ + type?: 'date' | 'month' | 'year'; + + /** + * 默认展示的起始月份 + * @en Default displayed start month + */ + defaultVisibleMonth?: () => Moment | null; + + /** + * 输入提示 + * @en Placeholder + */ + placeholder?: Array | string; + + /** + * 日期范围值数组 [moment, moment] + * @en Date range value array [moment, moment] + */ + value?: [start: Moment | string | null | undefined, end?: Moment | string | undefined | null]; + + /** + * 初始的日期范围值数组 [moment, moment] + * @en Initial date range value array [moment, moment] + */ + defaultValue?: [ + start: Moment | string | null | undefined, + end?: Moment | string | undefined | null, + ]; + + /** + * 日期值的格式(用于限定用户输入和展示) + * @en Date value format(for restricting user input and display) + */ + format?: string; + + /** + * 是否使用时间控件,传入 TimePicker 的属性 \{ defaultValue, format, ... \} + * @en Whether to use time control, pass the props of TimePicker \{ defaultValue, format, ... \} + * @defaultValue false + */ + showTime?: + | (Omit & { + defaultValue?: Moment | string | null | (Moment | string | null | undefined)[]; + }) + | boolean; + + /** + * 每次选择日期时是否重置时间(仅在 showTime 开启时有效) + * @en Whether to reset time when selecting a date(only valid when showTime is enabled) + * @defaultValue false + */ + resetTime?: boolean; + + /** + * 禁用日期函数 + * @en Disable date function + */ + disabledDate?: (date: Moment, view: string) => boolean; + + /** + * 自定义面板页脚 + * @en Custom panel footer + */ + footerRender?: () => React.ReactNode; + + /** + * 日期范围值改变时的回调 + * @en Callback when the date range value changes + */ + onChange?: (value: (string | Moment | null | undefined)[]) => void; + + /** + * 点击确认按钮时的回调 返回开始时间和结束时间 + * @en Callback when the confirm button is clicked + */ + onOk?: (value: (string | Moment | null | undefined)[]) => void; + + /** + * 输入框内置标签 + * @en Inset label of input + */ + label?: React.ReactNode; + + /** + * 输入框状态 + * @en Input status + */ + state?: 'error' | 'loading' | 'success'; + + /** + * 输入框尺寸 + * @en Input box size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + + /** + * 是否禁用 + * @en Whether to disable + */ + disabled?: boolean; + + /** + * 是否显示清空按钮 + * @en Whether to display the clear button + * @defaultValue true + */ + hasClear?: boolean; + + /** + * 弹层显示状态 + * @en Pop-up display status + */ + visible?: boolean; + + /** + * 弹层默认是否显示 + * @en Pop-up default display status + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 弹层展示状态变化时的回调 + * @en Callback when the pop-up display status changes + */ + onVisibleChange?: (visible: boolean, reason: string) => void; + + /** + * 弹层触发方式 + * @en Pop-up trigger + * @defaultValue 'click' + */ + popupTriggerType?: 'click' | 'hover'; + + /** + * 弹层对齐方式,具体含义见 OverLay 文档 + * @en Pop-up alignment, see OverLay documentation + * @defaultValue 'tl tl' + */ + popupAlign?: string; + + /** + * 弹层容器 + * @en Pop-up container + */ + popupContainer?: PopupProps['container']; + + /** + * 弹层自定义样式 + * @en Pop-up custom style + */ + popupStyle?: React.CSSProperties; + + /** + * 弹层自定义样式类 + * @en Pop-up custom style class + */ + popupClassName?: string; + + /** + * 弹层其他属性 + * @en Pop-up other attributes + */ + popupProps?: React.PropsWithRef; + + /** + * 输入框其他属性 + * @en Input box other attributes + */ + inputProps?: InputProps; + + /** + * 自定义日期渲染函数 + * @en Custom date rendering function + */ + dateCellRender?: () => void; + + /** + * 开始日期输入框的 aria-label 属性 + * @en Start date input box aria-label + */ + startDateInputAriaLabel?: string; + + /** + * 开始时间输入框的 aria-label 属性 + * @en Start time input box aria-label + */ + startTimeInputAriaLabel?: string; + + /** + * 结束日期输入框的 aria-label 属性 + * @en End date input box aria-label + */ + endDateInputAriaLabel?: string; + + /** + * 结束时间输入框的 aria-label 属性 + * @en End time input box aria-label + */ + endTimeInputAriaLabel?: string; + /** + * 预览态定制渲染函数 + * @en Preview state custom rendering function + */ + renderPreview?: ( + value: [Moment | null, Moment | null], + props: RangePickerProps + ) => React.ReactNode; + /** + * 展现的月份变化时的回调 + * @en Callback when the displayed month changes + */ + onVisibleMonthChange?: RangeCalendarProps['onVisibleMonthChange']; + /** + * @skip + */ + locale?: Locale['DatePicker']; + /** + * 自定义弹层 + * @en Custom pop-up + */ + popupComponent?: React.ComponentType; + + /** + * 自定义弹层内容 + * @en Custom pop-up content + */ + popupContent?: React.ReactElement; + /** + * 自定义月份渲染函数 + * @en Custom month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 是否跟随滚动 + * @en Whether Pop-up follows trigger when scrolling + */ + followTrigger?: boolean; + /** + * 是否为预览态 + * @en Whether it is a preview state + */ + isPreview?: boolean; + /** + * 年份范围,[START_YEAR, END_YEAR] + * @en Year range, [START_YEAR, END_YEAR] + */ + yearRange?: [start: number, end: number]; + /** + * @skip + * 兼容 0.x ranges 属性,用于显示一些快捷选择的入口 + * @en Compatible with 0.x ranges attribute, used to display some shortcut entry points + * @deprecated use footerRender instead + */ + ranges?: { + [key: string]: MomentInput[]; + }; + /** + * 禁用日期选择器的日期模式切换 + * @en Disable date selection + * @defaultValue false + */ + disableChangeMode?: boolean; +} + +export interface RangePickerState { + startValue?: Moment | null; + endValue?: Moment | null; + startTime?: Moment | null; + endTime?: Moment | null; + inputAsString?: boolean | undefined; + visible?: boolean; + startDateInputStr?: string; + endDateInputStr?: string; + activeDateInput?: 'startValue' | 'endValue' | 'startTime' | 'endTime'; + startTimeInputStr?: string; + endTimeInputStr?: string; + inputing?: boolean | string; + panel?: PanelType; + format?: string | undefined; + timeFormat?: string; + dateTimeFormat?: string | undefined; +} + +/** + * @api DatePicker.YearPicker + * @order 4 + */ +export interface YearPickerProps + extends Omit, 'onChange' | 'defaultValue'>, + CommonProps, + DeprecatedProps { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 输入框内置标签 + * @en Inset label of input + */ + label?: React.ReactNode; + + /** + * 输入框状态 + * @en Input status + */ + state?: 'success' | 'loading' | 'error'; + + /** + * 输入提示 + * @en Input prompt + */ + placeholder?: string; + + /** + * 日期值(受控)moment 对象 + * @en Date value (controlled) moment object + */ + value?: string | Moment | null; + + /** + * 初始日期值,moment 对象 + * @en Initial date value, moment object + */ + defaultValue?: string | Moment | null; + + /** + * 日期值的格式(用于限定用户输入和展示) + * @en Date value format (for limiting user input and display) + * @defaultValue 'YYYY' + */ + format?: string; + + /** + * 禁用日期函数 + * @en Disable date function + */ + disabledDate?: (date: Moment, view: string) => boolean; + + /** + * 自定义面板页脚 + * @en Custom panel footer + */ + footerRender?: () => React.ReactNode; + + /** + * 日期值改变时的回调 + * @en Callback when the date value changes + */ + onChange?: (value: Moment | string | null) => void; + + /** + * 输入框尺寸 + * @en Input box size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + + /** + * 是否禁用 + * @en Whether it is disabled + */ + disabled?: boolean; + + /** + * 是否显示清空按钮 + * @en Whether to display the clear button + * @defaultValue true + */ + hasClear?: boolean; + + /** + * 弹层显示状态 + * @en Pop-up display status + */ + visible?: boolean; + + /** + * 弹层默认是否显示 + * @en Pop-up default display status + */ + defaultVisible?: boolean; + + /** + * 弹层展示状态变化时的回调 + * @en Callback when the pop-up display status changes + */ + onVisibleChange?: (visible: boolean, reason: string) => void; + + /** + * 弹层触发方式 + * @en Pop-up trigger + * @defaultValue 'click' + */ + popupTriggerType?: 'click' | 'hover'; + + /** + * 弹层对齐方式,具体含义见 OverLay 文档 + * @en Pop-up alignment, see OverLay documentation + * @defaultValue 'tl tl' + */ + popupAlign?: string; + + /** + * 弹层容器 + * @en Pop-up container + */ + popupContainer?: PopupProps['container']; + + /** + * 弹层自定义样式 + * @en Pop-up custom style + */ + popupStyle?: React.CSSProperties; + + /** + * 弹层自定义样式类 + * @en Pop-up custom style class + */ + popupClassName?: string; + + /** + * 弹层其他属性 + * @en Pop-up other attributes + */ + popupProps?: React.PropsWithRef; + + /** + * 输入框其他属性 + * @en Input box other attributes + */ + inputProps?: InputProps; + + /** + * 日期输入框的 aria-label 属性 + * @en Date input box aria-label + */ + dateInputAriaLabel?: string; + /** + * 预览态定制渲染函数 + * @en Preview state custom rendering function + */ + renderPreview?: (value: Moment | null, props: DatePickerProps) => React.ReactNode; + /** + * 自定义弹层 + * @en Custom pop-up + */ + popupComponent?: React.ComponentType; + + /** + * 自定义弹层内容 + * @en Custom pop-up content + */ + popupContent?: React.ReactElement; + /** + * @skip + */ + locale?: Locale['DatePicker']; + /** + * 是否跟随滚动 + * @en Whether Pop-up follows trigger when scrolling + */ + followTrigger?: boolean; + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 是否为预览态 + * @en Whether it is a preview state + */ + isPreview?: boolean; +} + +export interface YearPickerState { + value: Moment | null; + inputAsString: boolean; + visible: boolean | undefined; + dateInputStr: string; + inputing: boolean; +} + +export interface PanelFooterProps extends Pick { + panel: PanelType; + onPanelChange?: ((panel: PanelType) => void) | null; + onOk: (value?: Moment[]) => void; + locale: Locale['DatePicker']; + disabledOk?: boolean; + ranges?: { label: React.Key; value?: MomentInput[]; onChange: (value: Moment[]) => void }[]; + value?: unknown; +} + +/** + * @api DatePicker.WeekPicker + * @order 5 + */ +export interface WeekPickerProps + extends Omit, 'onChange' | 'defaultValue'>, + CommonProps { + /** + * @deprecated use Form.Item name instead + * @skip + */ + name?: string; + /** + * 日期值(受控)moment 对象 + * @en Date value (controlled) moment object + */ + value?: string | Moment | null; + + /** + * 初始日期值,moment 对象 + * @en Initial date value, moment object + */ + defaultValue?: string | Moment | null; + /** + * 弹层显示状态 + * @en Pop-up display status + */ + visible?: boolean; + + /** + * 弹层默认是否显示 + * @en Pop-up default display status, uncontrolled + */ + defaultVisible?: boolean; + /** + * 日期值的格式(用于限定用户输入和展示) + * @en Date value format (for limiting user input and display) + * @defaultValue 'GGGG-Wo' + */ + format?: string; + /** + * 日期值改变时的回调 + * @en Callback when the date value changes + */ + onChange?: (value: Moment | string | null) => void; + /** + * 弹层展示状态变化时的回调 + * @en Callback when the pop-up display status changes + */ + onVisibleChange?: (visible: boolean, reason: string) => void; + /** + * 预览态定制渲染函数 + * @en Preview state custom rendering function + */ + renderPreview?: (value: Moment | null, props: DatePickerProps) => React.ReactNode; + /** + * 自定义日期渲染函数 + * @en Custom date rendering function + */ + dateCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * @skip + */ + locale?: Locale['DatePicker']; + /** + * 输入框内置标签 + * @en Input box built-in label + */ + label?: React.ReactNode; + /** + * 输入框状态 + * @en Input box status + */ + state?: 'success' | 'loading' | 'error'; + /** + * 默认展现的月 + * @en Default displayed month + * @defaultValue false + */ + defaultVisibleMonth?: () => Moment; + /** + * 弹层展示月份变化时的回调 + * @en Callback when the pop-up display month changes + */ + onVisibleMonthChange?: (value: Moment, reason: string) => void; + /** + * 禁用日期函数 + * @en Disable date function + */ + disabledDate?: (date: Moment, view: string) => boolean; + /** + * 自定义面板页脚 + * @en Custom panel footer + */ + footerRender?: () => React.ReactNode; + /** + * 输入框尺寸 + * @en Input box size + * @defaultValue 'medium' + */ + size?: 'small' | 'medium' | 'large'; + /** + * 是否禁用 + * @en Whether to disable + */ + disabled?: boolean; + /** + * 是否显示清空按钮 + * @en Whether to display the clear button + * @defaultValue true + */ + hasClear?: boolean; + /** + * 弹层触发方式 + * @en Pop-up trigger + * @defaultValue 'click' + */ + popupTriggerType?: 'click' | 'hover'; + /** + * 弹层对齐方式,具体含义见 OverLay 文档 + * @en Pop-up alignment, see OverLay documentation + * @defaultValue 'tl tl' + */ + popupAlign?: string; + /** + * 弹层容器 + * @en Pop-up container + */ + popupContainer?: PopupProps['container']; + + /** + * 弹层自定义样式 + * @en Pop-up custom style + */ + popupStyle?: React.CSSProperties; + + /** + * 弹层自定义样式类 + * @en Pop-up custom style class + */ + popupClassName?: string; + + /** + * 弹层其他属性 + * @en Pop-up other attributes + */ + popupProps?: React.PropsWithRef; + + /** + * 自定义弹层 + * @en Custom pop-up + */ + popupComponent?: React.ComponentType; + /** + * 自定义弹层内容 + * @en Custom pop-up content + */ + popupContent?: React.ReactElement; + /** + * 是否跟随滚动 + * @en Whether Pop-up follows trigger when scrolling + */ + followTrigger?: boolean; + /** + * 输入框其他属性 + * @en Input box other attributes + */ + inputProps?: InputProps; + /** + * 自定义月份渲染函数 + * @en Custom month rendering function + */ + monthCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 自定义年份渲染函数 + * @en Custom year rendering function + */ + yearCellRender?: (calendarDate: Moment) => React.ReactNode; + /** + * 是否为预览态 + * @en Whether it is a preview state + */ + isPreview?: boolean; +} + +export interface WeekPickerState { + value: Moment | null; + visible: boolean | undefined; +} + +export interface DeprecatedProps { + /** + * @deprecated use visible instead + */ + open?: boolean; + /** + * @deprecated use defaultVisible instead + */ + defaultOpen?: boolean; + /** + * @deprecated use onVisibleChange instead + */ + onOpenChange?: (open: boolean) => void; + /** + * @deprecated use format/showTime.format instead + */ + formater?: unknown; +} diff --git a/components/date-picker/util/index.js b/components/date-picker/util/index.js deleted file mode 100644 index 670ace700d..0000000000 --- a/components/date-picker/util/index.js +++ /dev/null @@ -1,159 +0,0 @@ -import moment from 'moment'; -import { KEYCODE } from '../../util'; - -export const PANEL = { - TIME: 'time-panel', - DATE: 'date-panel', -}; - -export const DEFAULT_TIME_FORMAT = 'HH:mm:ss'; - -export function isFunction(obj) { - return !!(obj && obj.constructor && obj.call && obj.apply); -} - -/** - * 将 source 的 time 替换为 target 的 time - * @param {Object} source 输入值 - * @param {Object} target 目标值 - */ -export function resetValueTime(source, target) { - if (!moment.isMoment(source) || !moment.isMoment(target)) { - return source; - } - return source - .clone() - .hour(target.hour()) - .minute(target.minute()) - .second(target.second()); -} - -export function formatDateValue(value, format) { - const val = typeof value === 'string' ? moment(value, format, false) : value; - if (val && moment.isMoment(val) && val.isValid()) { - return val; - } - - return null; -} - -export function checkDateValue(props, propName, componentName) { - // 支持传入 moment 对象或字符串,字符串不检测是否为日期字符串 - if (props[propName] && !moment.isMoment(props[propName]) && typeof props[propName] !== 'string') { - return new Error( - `Invalid prop ${propName} supplied to ${componentName}. Required a moment object or format date string!` - ); - } -} - -export function getDateTimeFormat(format, showTime, type) { - if (!format && type) { - format = { - date: 'YYYY-MM-DD', - month: 'YYYY-MM', - year: 'YYYY', - time: '', - }[type]; - } - const timeFormat = showTime ? showTime.format || DEFAULT_TIME_FORMAT : ''; - const dateTimeFormat = timeFormat ? `${format} ${timeFormat}` : format; - return { - format, - timeFormat, - dateTimeFormat, - }; -} - -export function extend(source, target) { - for (const key in source) { - if (source.hasOwnProperty(key)) { - target[key] = source[key]; - } - } - return target; -} - -/** - * 监听键盘事件,操作日期字符串 - * @param {KeyboardEvent} e 事件对象 - * @param {Object} param1 - * @param {String} type 类型 year month day - */ -export function onDateKeydown(e, { format, dateInputStr, value }, type) { - if ([KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) { - return; - } - - if ((e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || e.controlKey || e.shiftKey) { - return; - } - - let date = moment(dateInputStr, format, true); - - if (date.isValid()) { - const stepUnit = e.altKey ? 'year' : 'month'; - switch (e.keyCode) { - case KEYCODE.UP: - date.subtract(1, type); - break; - case KEYCODE.DOWN: - date.add(1, type); - break; - case KEYCODE.PAGE_UP: - date.subtract(1, stepUnit); - break; - case KEYCODE.PAGE_DOWN: - date.add(1, stepUnit); - break; - } - } else if (value) { - date = value.clone(); - } else { - date = moment(); - } - - e.preventDefault(); - return date.format(format); -} - -/** - * 监听键盘事件,操作时间 - * @param {KeyboardEvent} e - * @param {Object} param1 - * @param {String} type second hour minute - */ -export function onTimeKeydown(e, { format, timeInputStr, steps, value }, type) { - if ([KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) return; - if ((e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || e.controlKey || e.shiftKey) - return; - - let time = moment(timeInputStr, format, true); - - if (time.isValid()) { - const stepUnit = e.altKey ? 'hour' : 'minute'; - switch (e.keyCode) { - case KEYCODE.UP: - time.subtract(steps[type], type); - break; - case KEYCODE.DOWN: - time.add(steps[type], type); - break; - case KEYCODE.PAGE_UP: - time.subtract(steps[stepUnit], stepUnit); - break; - case KEYCODE.PAGE_DOWN: - time.add(steps[stepUnit], stepUnit); - break; - } - } else if (value) { - time = value.clone(); - } else { - time = moment() - .hours(0) - .minutes(0) - .seconds(0); - } - - e.preventDefault(); - return time.format(format); -} diff --git a/components/date-picker/util/index.ts b/components/date-picker/util/index.ts new file mode 100644 index 0000000000..9bc44e6694 --- /dev/null +++ b/components/date-picker/util/index.ts @@ -0,0 +1,211 @@ +import moment, { type MomentFormatSpecification, type Moment } from 'moment'; +import { type KeyboardEvent } from 'react'; +import { KEYCODE } from '../../util'; +import { type TimePickerProps } from '../../time-picker'; +import { type RangePickerProps } from '../types'; + +export const PANEL = { + TIME: 'time-panel', + DATE: 'date-panel', +} as const; + +export const DEFAULT_TIME_FORMAT = 'HH:mm:ss'; + +export function isFunction(obj: unknown) { + // @ts-expect-error 目前的写法 ts 不友好,其实可以写成更简洁的 typeof 判断 + return !!(obj && obj.constructor && obj.call && obj.apply); +} + +type ResetValueTimeReturn = T extends Moment ? (S extends Moment ? Moment : T) : T; + +/** + * 将 source 的 time 替换为 target 的 time + * @param source - 输入值 + * @param target - 目标值 + */ +export function resetValueTime(source: T, target: S): ResetValueTimeReturn { + if (!moment.isMoment(source) || !moment.isMoment(target)) { + return source as ResetValueTimeReturn; + } + return source + .clone() + .hour(target.hour()) + .minute(target.minute()) + .second(target.second()) as ResetValueTimeReturn; +} + +export function formatDateValue( + value: string | Moment | undefined | null, + format?: MomentFormatSpecification +) { + const val = typeof value === 'string' ? moment(value, format, false) : value; + if (val && moment.isMoment(val) && val.isValid()) { + return val; + } + + return null; +} + +export function checkDateValue( + props: Record, + propName: string, + componentName: string +) { + // 支持传入 moment 对象或字符串,字符串不检测是否为日期字符串 + if ( + props[propName] && + !moment.isMoment(props[propName]) && + typeof props[propName] !== 'string' + ) { + return new Error( + `Invalid prop ${propName} supplied to ${componentName}. Required a moment object or format date string!` + ); + } +} + +export function getDateTimeFormat( + format: string | undefined, + showTime: RangePickerProps['showTime'], + type?: 'date' | 'month' | 'year' | 'time' +) { + if (!format && type) { + format = { + date: 'YYYY-MM-DD', + month: 'YYYY-MM', + year: 'YYYY', + time: '', + }[type]; + } + const timeFormat = showTime ? (showTime as TimePickerProps).format || DEFAULT_TIME_FORMAT : ''; + const dateTimeFormat = timeFormat ? `${format} ${timeFormat}` : format; + return { + format, + timeFormat, + dateTimeFormat, + }; +} + +export function extend, T extends Record>( + source: S, + target: T +): S & T { + for (const key in source) { + if (source.hasOwnProperty(key)) { + (target as Record)[key] = source[key]; + } + } + return target as S & T; +} + +/** + * 监听键盘事件,操作日期字符串 + * @param e - 事件对象 + * @param param1 - 参数 + * @param type - 类型 year month day + */ +export function onDateKeydown( + e: KeyboardEvent, + { + format, + dateInputStr, + value, + }: { format?: string; dateInputStr: string; value?: Moment | null }, + type: 'year' | 'month' | 'day' +) { + if ([KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) { + return; + } + + if ( + (e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || + e.ctrlKey || + e.shiftKey + ) { + return; + } + + let date = moment(dateInputStr, format, true); + + if (date.isValid()) { + const stepUnit = e.altKey ? 'year' : 'month'; + switch (e.keyCode) { + case KEYCODE.UP: + date.subtract(1, type); + break; + case KEYCODE.DOWN: + date.add(1, type); + break; + case KEYCODE.PAGE_UP: + date.subtract(1, stepUnit); + break; + case KEYCODE.PAGE_DOWN: + date.add(1, stepUnit); + break; + } + } else if (value) { + date = value.clone(); + } else { + date = moment(); + } + + e.preventDefault(); + return date.format(format); +} + +/** + * 监听键盘事件,操作时间 + * @param e - 事件对象 + * @param param1 - 参数 + * @param type - second hour minute + */ +export function onTimeKeydown( + e: KeyboardEvent, + { + format, + timeInputStr, + steps, + value, + }: { + format: string; + timeInputStr: string; + steps: Record; + value?: Moment | null; + }, + type: 'second' | 'minute' | 'hour' +) { + if ([KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) + return; + if ( + (e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || + e.ctrlKey || + e.shiftKey + ) + return; + + let time = moment(timeInputStr, format, true); + + if (time.isValid()) { + const stepUnit = e.altKey ? 'hour' : 'minute'; + switch (e.keyCode) { + case KEYCODE.UP: + time.subtract(steps[type], type); + break; + case KEYCODE.DOWN: + time.add(steps[type], type); + break; + case KEYCODE.PAGE_UP: + time.subtract(steps[stepUnit], stepUnit); + break; + case KEYCODE.PAGE_DOWN: + time.add(steps[stepUnit], stepUnit); + break; + } + } else if (value) { + time = value.clone(); + } else { + time = moment().hours(0).minutes(0).seconds(0); + } + + e.preventDefault(); + return time.format(format); +} diff --git a/components/date-picker/week-picker.jsx b/components/date-picker/week-picker.jsx deleted file mode 100644 index 906d525d32..0000000000 --- a/components/date-picker/week-picker.jsx +++ /dev/null @@ -1,435 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import classnames from 'classnames'; -import moment from 'moment'; -import { polyfill } from 'react-lifecycles-compat'; -import Overlay from '../overlay'; -import Input from '../input'; -import Icon from '../icon'; -import Calendar from '../calendar'; -import ConfigProvider from '../config-provider'; -import nextLocale from '../locale/zh-cn'; -import { func, obj, KEYCODE } from '../util'; -import { checkDateValue, formatDateValue } from './util'; - -const { Popup } = Overlay; - -/** - * DatePicker.WeekPicker - */ -class WeekPicker extends Component { - static propTypes = { - ...ConfigProvider.propTypes, - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 输入框内置标签 - */ - label: PropTypes.node, - /** - * 输入框状态 - */ - state: PropTypes.oneOf(['success', 'loading', 'error']), - /** - * 输入提示 - */ - placeholder: PropTypes.string, - /** - * 默认展现的月 - * @return {MomentObject} 返回包含指定月份的 moment 对象实例 - */ - defaultVisibleMonth: PropTypes.func, - onVisibleMonthChange: PropTypes.func, - /** - * 日期值(受控)moment 对象 - */ - value: checkDateValue, - /** - * 初始日期值,moment 对象 - */ - defaultValue: checkDateValue, - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format: PropTypes.string, - /** - * 禁用日期函数 - * @param {MomentObject} 日期值 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @return {Boolean} 是否禁用 - */ - disabledDate: PropTypes.func, - /** - * 自定义面板页脚 - * @return {Node} 自定义的面板页脚组件 - */ - footerRender: PropTypes.func, - /** - * 日期值改变时的回调 - * @param {MomentObject|String} value 日期值 - */ - onChange: PropTypes.func, - /** - * 输入框尺寸 - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * 是否禁用 - */ - disabled: PropTypes.bool, - /** - * 是否显示清空按钮 - */ - hasClear: PropTypes.bool, - /** - * 弹层显示状态 - */ - visible: PropTypes.bool, - /** - * 弹层默认是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层展示状态变化时的回调 - * @param {Boolean} visible 弹层是否显示 - * @param {String} type 触发弹层显示和隐藏的来源 calendarSelect 表示由日期表盘的选择触发; okBtnClick 表示由确认按钮触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 弹层触发方式 - */ - popupTriggerType: PropTypes.oneOf(['click', 'hover']), - /** - * 弹层对齐方式,具体含义见 OverLay文档 - */ - popupAlign: PropTypes.string, - /** - * 弹层容器 - * @param {Element} target 目标元素 - * @return {Element} 弹层的容器元素 - */ - popupContainer: PropTypes.any, - /** - * 弹层自定义样式 - */ - popupStyle: PropTypes.object, - /** - * 弹层自定义样式类 - */ - popupClassName: PropTypes.string, - /** - * 弹层其他属性 - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 输入框其他属性 - */ - inputProps: PropTypes.object, - /** - * 自定义日期渲染函数 - * @param {Object} value 日期值(moment对象) - * @returns {ReactNode} - */ - dateCellRender: PropTypes.func, - /** - * 自定义月份渲染函数 - * @param {Object} calendarDate 对应 Calendar 返回的自定义日期对象 - * @returns {ReactNode} - */ - monthCellRender: PropTypes.func, - /** - * 是否为预览态 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {MomentObject} value 年份 - */ - renderPreview: PropTypes.func, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - locale: PropTypes.object, - className: PropTypes.string, - name: PropTypes.string, - popupComponent: PropTypes.elementType, - popupContent: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - format: 'GGGG-Wo', - size: 'medium', - disabledDate: () => false, - footerRender: () => null, - hasClear: true, - popupTriggerType: 'click', - popupAlign: 'tl tl', - locale: nextLocale.DatePicker, - defaultVisible: false, - onChange: func.noop, - onVisibleChange: func.noop, - }; - - constructor(props, context) { - super(props, context); - - const value = formatDateValue(props.value || props.defaultValue, props.format); - - this.state = { - value, - visible: props.visible || props.defaultVisible, - }; - } - - static getDerivedStateFromProps(props) { - const st = {}; - if ('value' in props) { - st.value = formatDateValue(props.value, props.format); - } - - if ('visible' in props) { - st.visible = props.visible; - } - - return st; - } - - handleChange = (newValue, prevValue) => { - if (!('value' in this.props)) { - this.setState({ - value: newValue, - }); - } - - const newValueOf = newValue ? newValue.valueOf() : null; - const preValueOf = prevValue ? prevValue.valueOf() : null; - - if (newValueOf !== preValueOf) { - this.props.onChange(newValue); - } - }; - - onDateInputChange = (value, e, eventType) => { - if (eventType === 'clear' || !value) { - e.stopPropagation(); - this.handleChange(null, this.state.value); - } - }; - - onKeyDown = e => { - if ([KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) { - return; - } - - if ( - (e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || - e.controlKey || - e.shiftKey - ) { - return; - } - - let date = this.state.value; - - if (date && date.isValid()) { - const stepUnit = e.altKey ? 'year' : 'month'; - switch (e.keyCode) { - case KEYCODE.UP: - date.subtract(1, 'w'); - break; - case KEYCODE.DOWN: - date.add(1, 'w'); - break; - case KEYCODE.PAGE_UP: - date.subtract(1, stepUnit); - break; - case KEYCODE.PAGE_DOWN: - date.add(1, stepUnit); - break; - } - } else { - date = moment(); - } - - e.preventDefault(); - - this.handleChange(date, this.state.value); - }; - - onVisibleChange = (visible, type) => { - if (!('visible' in this.props)) { - this.setState({ - visible, - }); - } - this.props.onVisibleChange(visible, type); - }; - - onSelectCalendarPanel = value => { - this.handleChange(value, this.state.value); - this.onVisibleChange(false, 'calendarSelect'); - }; - - renderPreview(others) { - const { prefix, format, className, renderPreview } = this.props; - const { value } = this.state; - const previewCls = classnames(className, `${prefix}form-preview`); - - const label = value ? value.format(format) : ''; - - if (typeof renderPreview === 'function') { - return ( -
    - {renderPreview(value, this.props)} -
    - ); - } - - return ( -

    - {label} -

    - ); - } - - dateRender = value => { - const { prefix, dateCellRender } = this.props; - const selectedValue = this.state.value; - const content = dateCellRender && typeof dateCellRender === 'function' ? dateCellRender(value) : value.dates(); - if (selectedValue && selectedValue.years() === value.years() && selectedValue.weeks() === value.weeks()) { - const firstDay = moment.localeData().firstDayOfWeek(); - const endDay = firstDay - 1 < 0 ? 6 : firstDay - 1; - return ( -
    - {content} -
    - ); - } - - return content; - }; - - render() { - const { - prefix, - rtl, - locale, - label, - state, - format, - defaultVisibleMonth, - onVisibleMonthChange, - disabledDate, - footerRender, - placeholder, - size, - disabled, - hasClear, - popupTriggerType, - popupAlign, - popupContainer, - popupStyle, - popupClassName, - popupProps, - popupComponent, - popupContent, - followTrigger, - className, - inputProps, - monthCellRender, - yearCellRender, - isPreview, - ...others - } = this.props; - const { visible, value } = this.state; - - const sharedInputProps = { - ...inputProps, - size, - disabled, - onChange: this.onDateInputChange, - onKeyDown: this.onKeyDown, - }; - - if (rtl) { - others.dir = 'rtl'; - } - - if (isPreview) { - return this.renderPreview(obj.pickOthers(others, WeekPicker.PropTypes)); - } - - const trigger = ( -
    - } - hasClear={value && hasClear} - className={`${prefix}week-picker-input`} - /> -
    - ); - - const PopupComponent = popupComponent ? popupComponent : Popup; - - return ( -
    - - {popupContent ? ( - popupContent - ) : ( -
    - - {footerRender()} -
    - )} -
    -
    - ); - } -} - -export default polyfill(WeekPicker); diff --git a/components/date-picker/week-picker.tsx b/components/date-picker/week-picker.tsx new file mode 100644 index 0000000000..27c4be65bd --- /dev/null +++ b/components/date-picker/week-picker.tsx @@ -0,0 +1,364 @@ +import React, { + Component, + type HTMLAttributes, + type KeyboardEvent, + type SyntheticEvent, +} from 'react'; +import PropTypes from 'prop-types'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import { polyfill } from 'react-lifecycles-compat'; +import Overlay from '../overlay'; +import Input from '../input'; +import Icon from '../icon'; +import Calendar from '../calendar'; +import ConfigProvider from '../config-provider'; +import nextLocale from '../locale/zh-cn'; +import { func, obj, KEYCODE, type ClassPropsWithDefault } from '../util'; +import { checkDateValue, formatDateValue } from './util'; +import type { WeekPickerProps, WeekPickerState } from './types'; + +const { Popup } = Overlay; + +type InnerWeekPickerProps = ClassPropsWithDefault; + +/** + * DatePicker.WeekPicker + */ +class WeekPicker extends Component { + static displayName = 'WeekPicker'; + static propTypes = { + ...ConfigProvider.propTypes, + prefix: PropTypes.string, + rtl: PropTypes.bool, + label: PropTypes.node, + state: PropTypes.oneOf(['success', 'loading', 'error']), + placeholder: PropTypes.string, + defaultVisibleMonth: PropTypes.func, + onVisibleMonthChange: PropTypes.func, + value: checkDateValue, + defaultValue: checkDateValue, + format: PropTypes.string, + disabledDate: PropTypes.func, + footerRender: PropTypes.func, + onChange: PropTypes.func, + size: PropTypes.oneOf(['small', 'medium', 'large']), + disabled: PropTypes.bool, + hasClear: PropTypes.bool, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + popupTriggerType: PropTypes.oneOf(['click', 'hover']), + popupAlign: PropTypes.string, + popupContainer: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + inputProps: PropTypes.object, + dateCellRender: PropTypes.func, + monthCellRender: PropTypes.func, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + yearCellRender: PropTypes.func, + locale: PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + popupComponent: PropTypes.elementType, + popupContent: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + format: 'GGGG-Wo', + size: 'medium', + disabledDate: () => false, + footerRender: () => null, + hasClear: true, + popupTriggerType: 'click', + popupAlign: 'tl tl', + locale: nextLocale.DatePicker, + defaultVisible: false, + onChange: func.noop, + onVisibleChange: func.noop, + }; + + readonly props: InnerWeekPickerProps; + + constructor(props: WeekPickerProps) { + super(props); + + const value = formatDateValue(props.value || props.defaultValue, props.format); + + this.state = { + value, + visible: props.visible || props.defaultVisible, + }; + } + + static getDerivedStateFromProps(props: InnerWeekPickerProps) { + const st: Partial = {}; + if ('value' in props) { + st.value = formatDateValue(props.value, props.format); + } + + if ('visible' in props) { + st.visible = props.visible; + } + + return st; + } + + handleChange = (newValue: Moment | null, prevValue: Moment | null) => { + if (!('value' in this.props)) { + this.setState({ + value: newValue, + }); + } + + const newValueOf = newValue ? newValue.valueOf() : null; + const preValueOf = prevValue ? prevValue.valueOf() : null; + + if (newValueOf !== preValueOf) { + this.props.onChange(newValue); + } + }; + + onDateInputChange = (value: Moment | null, e: SyntheticEvent, eventType: string) => { + if (eventType === 'clear' || !value) { + e.stopPropagation(); + this.handleChange(null, this.state.value); + } + }; + + onKeyDown = (e: KeyboardEvent) => { + if ( + [KEYCODE.UP, KEYCODE.DOWN, KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1 + ) { + return; + } + + if ( + (e.altKey && [KEYCODE.PAGE_UP, KEYCODE.PAGE_DOWN].indexOf(e.keyCode) === -1) || + e.ctrlKey || + e.shiftKey + ) { + return; + } + + let date = this.state.value; + + if (date && date.isValid()) { + const stepUnit = e.altKey ? 'year' : 'month'; + switch (e.keyCode) { + case KEYCODE.UP: + date.subtract(1, 'w'); + break; + case KEYCODE.DOWN: + date.add(1, 'w'); + break; + case KEYCODE.PAGE_UP: + date.subtract(1, stepUnit); + break; + case KEYCODE.PAGE_DOWN: + date.add(1, stepUnit); + break; + } + } else { + date = moment(); + } + + e.preventDefault(); + + this.handleChange(date, this.state.value); + }; + + onVisibleChange = (visible: boolean, type: string) => { + if (!('visible' in this.props)) { + this.setState({ + visible, + }); + } + this.props.onVisibleChange(visible, type); + }; + + onSelectCalendarPanel = (value: Moment | null) => { + this.handleChange(value, this.state.value); + this.onVisibleChange(false, 'calendarSelect'); + }; + + renderPreview(others: HTMLAttributes) { + const { prefix, format, className, renderPreview } = this.props; + const { value } = this.state; + const previewCls = classnames(className, `${prefix}form-preview`); + + const label = value ? value.format(format) : ''; + + if (typeof renderPreview === 'function') { + return ( +
    + {renderPreview(value, this.props)} +
    + ); + } + + return ( +

    + {label} +

    + ); + } + + dateRender = (value: Moment) => { + const { prefix, dateCellRender } = this.props; + const selectedValue = this.state.value; + const content = + dateCellRender && typeof dateCellRender === 'function' + ? dateCellRender(value) + : value.dates(); + if ( + selectedValue && + selectedValue.years() === value.years() && + selectedValue.weeks() === value.weeks() + ) { + const firstDay = moment.localeData().firstDayOfWeek(); + const endDay = firstDay - 1 < 0 ? 6 : firstDay - 1; + return ( +
    + {content} +
    + ); + } + + return content; + }; + + render() { + const { + prefix, + rtl, + locale, + label, + state, + format, + defaultVisibleMonth, + onVisibleMonthChange, + disabledDate, + footerRender, + placeholder, + size, + disabled, + hasClear, + popupTriggerType, + popupAlign, + popupContainer, + popupStyle, + popupClassName, + popupProps, + popupComponent, + popupContent, + followTrigger, + className, + inputProps, + monthCellRender, + yearCellRender, + isPreview, + ...others + } = this.props; + const { visible, value } = this.state; + + const sharedInputProps = { + ...inputProps, + size, + disabled, + onChange: this.onDateInputChange, + onKeyDown: this.onKeyDown, + }; + + if (rtl) { + others.dir = 'rtl'; + } + + if (isPreview) { + // @ts-expect-error 应是 propTypes + return this.renderPreview(obj.pickOthers(others, WeekPicker.PropTypes)); + } + + const trigger = ( +
    + + } + // @ts-expect-error allowClear 应该先做 boolean 化处理 + hasClear={value && hasClear} + className={`${prefix}week-picker-input`} + /> +
    + ); + + const PopupComponent = popupComponent ? popupComponent : Popup; + + return ( +
    + + {popupContent ? ( + popupContent + ) : ( +
    + + {footerRender()} +
    + )} +
    +
    + ); + } +} + +export default polyfill(WeekPicker); diff --git a/components/date-picker/year-picker.jsx b/components/date-picker/year-picker.jsx deleted file mode 100644 index df4cc4f495..0000000000 --- a/components/date-picker/year-picker.jsx +++ /dev/null @@ -1,456 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import { polyfill } from 'react-lifecycles-compat'; -import classnames from 'classnames'; -import moment from 'moment'; -import Overlay from '../overlay'; -import Input from '../input'; -import Icon from '../icon'; -import Calendar from '../calendar'; -import nextLocale from '../locale/zh-cn'; -import { func, obj } from '../util'; -import { checkDateValue, formatDateValue, onDateKeydown } from './util'; - -const { Popup } = Overlay; - -/** - * DatePicker.YearPicker - */ -class YearPicker extends Component { - static propTypes = { - prefix: PropTypes.string, - rtl: PropTypes.bool, - /** - * 输入框内置标签 - */ - label: PropTypes.node, - /** - * 输入框状态 - */ - state: PropTypes.oneOf(['success', 'loading', 'error']), - /** - * 输入提示 - */ - placeholder: PropTypes.string, - /** - * 日期值(受控)moment 对象 - */ - value: checkDateValue, - /** - * 初始日期值,moment 对象 - */ - defaultValue: checkDateValue, - /** - * 日期值的格式(用于限定用户输入和展示) - */ - format: PropTypes.string, - /** - * 禁用日期函数 - * @param {MomentObject} 日期值 - * @param {String} view 当前视图类型,year: 年, month: 月, date: 日 - * @return {Boolean} 是否禁用 - */ - disabledDate: PropTypes.func, - /** - * 自定义面板页脚 - * @return {Node} 自定义的面板页脚组件 - */ - footerRender: PropTypes.func, - /** - * 日期值改变时的回调 - * @param {MomentObject|String} value 日期值 - */ - onChange: PropTypes.func, - /** - * 输入框尺寸 - */ - size: PropTypes.oneOf(['small', 'medium', 'large']), - /** - * 是否禁用 - */ - disabled: PropTypes.bool, - /** - * 是否显示清空按钮 - */ - hasClear: PropTypes.bool, - /** - * 弹层显示状态 - */ - visible: PropTypes.bool, - /** - * 弹层默认是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层展示状态变化时的回调 - * @param {Boolean} visible 弹层是否显示 - * @param {String} reason 触发弹层显示和隐藏的来源 calendarSelect 表示由日期表盘的选择触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 弹层触发方式 - */ - popupTriggerType: PropTypes.oneOf(['click', 'hover']), - /** - * 弹层对齐方式, 具体含义见 OverLay文档 - */ - popupAlign: PropTypes.string, - /** - * 弹层容器 - * @param {Element} target 目标元素 - * @return {Element} 弹层的容器元素 - */ - popupContainer: PropTypes.any, - /** - * 弹层自定义样式 - */ - popupStyle: PropTypes.object, - /** - * 弹层自定义样式类 - */ - popupClassName: PropTypes.string, - /** - * 弹层其他属性 - */ - popupProps: PropTypes.object, - /** - * 是否跟随滚动 - */ - followTrigger: PropTypes.bool, - /** - * 输入框其他属性 - */ - inputProps: PropTypes.object, - yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender - /** - * 日期输入框的 aria-label 属性 - */ - dateInputAriaLabel: PropTypes.string, - /** - * 是否为预览态 - */ - isPreview: PropTypes.bool, - /** - * 预览态模式下渲染的内容 - * @param {MomentObject} value 年份 - */ - renderPreview: PropTypes.func, - locale: PropTypes.object, - className: PropTypes.string, - name: PropTypes.string, - popupComponent: PropTypes.elementType, - popupContent: PropTypes.node, - }; - - static defaultProps = { - prefix: 'next-', - rtl: false, - format: 'YYYY', - size: 'medium', - disabledDate: () => false, - footerRender: () => null, - hasClear: true, - popupTriggerType: 'click', - popupAlign: 'tl tl', - locale: nextLocale.DatePicker, - onChange: func.noop, - onVisibleChange: func.noop, - }; - - constructor(props, context) { - super(props, context); - - this.state = { - value: formatDateValue(props.defaultValue, props.format), - dateInputStr: '', - inputing: false, - visible: props.defaultVisible, - inputAsString: typeof props.defaultValue === 'string', // 判断用户输入是否是字符串 - }; - } - - static getDerivedStateFromProps(props) { - const states = {}; - if ('value' in props) { - states.value = formatDateValue(props.value, props.format); - states.inputAsString = typeof props.value === 'string'; - } - - if ('visible' in props) { - states.visible = props.visible; - } - - return states; - } - - onValueChange = newValue => { - const ret = this.state.inputAsString && newValue ? newValue.format(this.props.format) : newValue; - this.props.onChange(ret); - }; - - onSelectCalendarPanel = value => { - // const { format } = this.props; - const prevSelectedMonth = this.state.value; - const selectedMonth = value - .clone() - .month(0) - .date(1) - .hour(0) - .minute(0) - .second(0); - - this.handleChange(selectedMonth, prevSelectedMonth, { inputing: false }, () => { - this.onVisibleChange(false, 'calendarSelect'); - }); - }; - - clearValue = () => { - this.setState({ - dateInputStr: '', - }); - - this.handleChange(null, this.state.value); - }; - - onDateInputChange = (inputStr, e, eventType) => { - if (eventType === 'clear' || !inputStr) { - e.stopPropagation(); - this.clearValue(); - } else { - this.setState({ - dateInputStr: inputStr, - inputing: true, - }); - } - }; - - onDateInputBlur = () => { - const { dateInputStr } = this.state; - if (dateInputStr) { - const { disabledDate, format } = this.props; - const parsed = moment(dateInputStr, format, true); - - this.setState({ - dateInputStr: '', - inputing: false, - }); - - if (parsed.isValid() && !disabledDate(parsed, 'year')) { - this.handleChange(parsed, this.state.value); - } - } - }; - - onKeyDown = e => { - const { format } = this.props; - const { dateInputStr, value } = this.state; - const dateStr = onDateKeydown(e, { format, dateInputStr, value }, 'year'); - if (!dateStr) return; - this.onDateInputChange(dateStr); - }; - - handleChange = (newValue, prevValue, others = {}, callback) => { - if (!('value' in this.props)) { - this.setState({ - value: newValue, - ...others, - }); - } else { - this.setState({ - ...others, - }); - } - - const { format } = this.props; - - const newValueOf = newValue ? newValue.format(format) : null; - const preValueOf = prevValue ? prevValue.format(format) : null; - - if (newValueOf !== preValueOf) { - this.onValueChange(newValue); - if (typeof callback === 'function') { - return callback(); - } - } - }; - - onVisibleChange = (visible, reason) => { - if (!('visible' in this.props)) { - this.setState({ - visible, - }); - } - this.props.onVisibleChange(visible, reason); - }; - - renderPreview(others) { - const { prefix, format, className, renderPreview } = this.props; - const { value } = this.state; - const previewCls = classnames(className, `${prefix}form-preview`); - - const label = value ? value.format(format) : ''; - - if (typeof renderPreview === 'function') { - return ( -
    - {renderPreview(value, this.props)} -
    - ); - } - - return ( -

    - {label} -

    - ); - } - - render() { - const { - prefix, - rtl, - locale, - label, - state, - format, - disabledDate, - footerRender, - placeholder, - size, - disabled, - hasClear, - popupTriggerType, - popupAlign, - popupContainer, - popupStyle, - popupClassName, - popupProps, - popupComponent, - popupContent, - followTrigger, - className, - inputProps, - dateInputAriaLabel, - yearCellRender, - isPreview, - ...others - } = this.props; - - const { visible, value, dateInputStr, inputing } = this.state; - - const yearPickerCls = classnames( - { - [`${prefix}year-picker`]: true, - }, - className - ); - - const triggerInputCls = classnames({ - [`${prefix}year-picker-input`]: true, - [`${prefix}error`]: false, - }); - - const panelBodyClassName = classnames({ - [`${prefix}year-picker-body`]: true, - }); - - if (rtl) { - others.dir = 'rtl'; - } - - if (isPreview) { - return this.renderPreview(obj.pickOthers(others, YearPicker.PropTypes)); - } - - const panelInputCls = `${prefix}year-picker-panel-input`; - - const sharedInputProps = { - ...inputProps, - size, - disabled, - onChange: this.onDateInputChange, - onBlur: this.onDateInputBlur, - onPressEnter: this.onDateInputBlur, - onKeyDown: this.onKeyDown, - }; - - const dateInputValue = inputing ? dateInputStr : (value && value.format(format)) || ''; - const triggerInputValue = dateInputValue; - - const dateInput = ( - - ); - - const datePanel = ( - - ); - - const panelBody = datePanel; - const panelFooter = footerRender(); - - const allowClear = value && hasClear; - const trigger = ( -
    - } - hasClear={allowClear} - className={triggerInputCls} - /> -
    - ); - - const PopupComponent = popupComponent ? popupComponent : Popup; - - return ( -
    - - {popupContent ? ( - popupContent - ) : ( -
    -
    {dateInput}
    - {panelBody} - {panelFooter} -
    - )} -
    -
    - ); - } -} - -export default polyfill(YearPicker); diff --git a/components/date-picker/year-picker.tsx b/components/date-picker/year-picker.tsx new file mode 100644 index 0000000000..a3265c1a2a --- /dev/null +++ b/components/date-picker/year-picker.tsx @@ -0,0 +1,391 @@ +import React, { + Component, + type HTMLAttributes, + type KeyboardEvent, + type SyntheticEvent, +} from 'react'; +import PropTypes from 'prop-types'; +import { polyfill } from 'react-lifecycles-compat'; +import classnames from 'classnames'; +import moment, { type Moment } from 'moment'; +import Overlay from '../overlay'; +import Input from '../input'; +import Icon from '../icon'; +import Calendar from '../calendar'; +import nextLocale from '../locale/zh-cn'; +import { type ClassPropsWithDefault, func, obj } from '../util'; +import { checkDateValue, formatDateValue, onDateKeydown } from './util'; +import type { YearPickerProps, YearPickerState } from './types'; + +const { Popup } = Overlay; + +type InnerYearPickerProps = ClassPropsWithDefault; + +/** + * DatePicker.YearPicker + */ +class YearPicker extends Component { + static displayName = 'YearPicker'; + static propTypes = { + prefix: PropTypes.string, + rtl: PropTypes.bool, + label: PropTypes.node, + state: PropTypes.oneOf(['success', 'loading', 'error']), + placeholder: PropTypes.string, + value: checkDateValue, + defaultValue: checkDateValue, + format: PropTypes.string, + disabledDate: PropTypes.func, + footerRender: PropTypes.func, + onChange: PropTypes.func, + size: PropTypes.oneOf(['small', 'medium', 'large']), + disabled: PropTypes.bool, + hasClear: PropTypes.bool, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + popupTriggerType: PropTypes.oneOf(['click', 'hover']), + popupAlign: PropTypes.string, + popupContainer: PropTypes.any, + popupStyle: PropTypes.object, + popupClassName: PropTypes.string, + popupProps: PropTypes.object, + followTrigger: PropTypes.bool, + inputProps: PropTypes.object, + yearCellRender: PropTypes.func, // 兼容 0.x yearCellRender + dateInputAriaLabel: PropTypes.string, + isPreview: PropTypes.bool, + renderPreview: PropTypes.func, + locale: PropTypes.object, + className: PropTypes.string, + name: PropTypes.string, + popupComponent: PropTypes.elementType, + popupContent: PropTypes.node, + }; + + static defaultProps = { + prefix: 'next-', + rtl: false, + format: 'YYYY', + size: 'medium', + disabledDate: () => false, + footerRender: () => null, + hasClear: true, + popupTriggerType: 'click', + popupAlign: 'tl tl', + locale: nextLocale.DatePicker, + onChange: func.noop, + onVisibleChange: func.noop, + }; + + readonly props: InnerYearPickerProps; + + constructor(props: YearPickerProps) { + super(props); + + this.state = { + value: formatDateValue(props.defaultValue, props.format), + dateInputStr: '', + inputing: false, + visible: props.defaultVisible, + inputAsString: typeof props.defaultValue === 'string', // 判断用户输入是否是字符串 + }; + } + + static getDerivedStateFromProps(props: InnerYearPickerProps) { + const states: Partial = {}; + if ('value' in props) { + states.value = formatDateValue(props.value, props.format); + if (typeof props.value === 'string') { + states.inputAsString = true; + } + if (moment.isMoment(props.value)) { + states.inputAsString = false; + } + } + + if ('visible' in props) { + states.visible = props.visible; + } + + return states; + } + + onValueChange = (newValue: Moment | null) => { + const ret = + this.state.inputAsString && newValue ? newValue.format(this.props.format) : newValue; + this.props.onChange(ret); + }; + + onSelectCalendarPanel = (value: Moment) => { + const prevSelectedMonth = this.state.value; + const selectedMonth = value.clone().month(0).date(1).hour(0).minute(0).second(0); + + this.handleChange(selectedMonth, prevSelectedMonth, { inputing: false }, () => { + this.onVisibleChange(false, 'calendarSelect'); + }); + }; + + clearValue = () => { + this.setState({ + dateInputStr: '', + }); + + this.handleChange(null, this.state.value); + }; + + onDateInputChange = (inputStr: string, e: SyntheticEvent, eventType?: string) => { + if (eventType === 'clear' || !inputStr) { + e.stopPropagation(); + this.clearValue(); + } else { + this.setState({ + dateInputStr: inputStr, + inputing: true, + }); + } + }; + + onDateInputBlur = () => { + const { dateInputStr } = this.state; + if (dateInputStr) { + const { disabledDate, format } = this.props; + const parsed = moment(dateInputStr, format, true); + + this.setState({ + dateInputStr: '', + inputing: false, + }); + + if (parsed.isValid() && !disabledDate(parsed, 'year')) { + this.handleChange(parsed, this.state.value); + } + } + }; + + onKeyDown = (e: KeyboardEvent) => { + const { format } = this.props; + const { dateInputStr, value } = this.state; + const dateStr = onDateKeydown(e, { format, dateInputStr, value }, 'year'); + if (!dateStr) return; + // @ts-expect-error 应该传入 e + this.onDateInputChange(dateStr); + }; + + handleChange = ( + newValue: Moment | null, + prevValue: Moment | null, + others = {}, + callback?: () => void + ) => { + if (!('value' in this.props)) { + this.setState({ + value: newValue, + ...others, + }); + } else { + this.setState({ + ...others, + }); + } + + const { format } = this.props; + + const newValueOf = newValue ? newValue.format(format) : null; + const preValueOf = prevValue ? prevValue.format(format) : null; + + if (newValueOf !== preValueOf) { + this.onValueChange(newValue); + if (typeof callback === 'function') { + return callback(); + } + } + }; + + onVisibleChange = (visible: boolean, reason: string) => { + if (!('visible' in this.props)) { + this.setState({ + visible, + }); + } + this.props.onVisibleChange(visible, reason); + }; + + renderPreview(others: HTMLAttributes) { + const { prefix, format, className, renderPreview } = this.props; + const { value } = this.state; + const previewCls = classnames(className, `${prefix}form-preview`); + + const label = value ? value.format(format) : ''; + + if (typeof renderPreview === 'function') { + return ( +
    + {renderPreview(value, this.props)} +
    + ); + } + + return ( +

    + {label} +

    + ); + } + + render() { + const { + prefix, + rtl, + locale, + label, + state, + format, + disabledDate, + footerRender, + placeholder, + size, + disabled, + hasClear, + popupTriggerType, + popupAlign, + popupContainer, + popupStyle, + popupClassName, + popupProps, + popupComponent, + popupContent, + followTrigger, + className, + inputProps, + dateInputAriaLabel, + yearCellRender, + isPreview, + ...others + } = this.props; + + const { visible, value, dateInputStr, inputing } = this.state; + + const yearPickerCls = classnames( + { + [`${prefix}year-picker`]: true, + }, + className + ); + + const triggerInputCls = classnames({ + [`${prefix}year-picker-input`]: true, + [`${prefix}error`]: false, + }); + + const panelBodyClassName = classnames({ + [`${prefix}year-picker-body`]: true, + }); + + if (rtl) { + others.dir = 'rtl'; + } + + if (isPreview) { + // @ts-expect-error 应是 propTypes + return this.renderPreview(obj.pickOthers(others, YearPicker.PropTypes)); + } + + const panelInputCls = `${prefix}year-picker-panel-input`; + + const sharedInputProps = { + ...inputProps, + size, + disabled, + onChange: this.onDateInputChange, + onBlur: this.onDateInputBlur, + onPressEnter: this.onDateInputBlur, + onKeyDown: this.onKeyDown, + }; + + const dateInputValue = inputing ? dateInputStr : (value && value.format(format)) || ''; + const triggerInputValue = dateInputValue; + + const dateInput = ( + + ); + + const datePanel = ( + + ); + + const panelBody = datePanel; + const panelFooter = footerRender(); + + const allowClear = value && hasClear; + const trigger = ( +
    + + } + // @ts-expect-error allowClear 应该先做 boolean 化处理 + hasClear={allowClear} + className={triggerInputCls} + /> +
    + ); + + const PopupComponent = popupComponent ? popupComponent : Popup; + + return ( +
    + + {popupContent ? ( + popupContent + ) : ( +
    +
    {dateInput}
    + {panelBody} + {panelFooter} +
    + )} +
    +
    + ); + } +} + +export default polyfill(YearPicker); diff --git a/components/date-picker2/__docs__/demo/format/index.tsx b/components/date-picker2/__docs__/demo/format/index.tsx index 45a2328c57..a5d45914e4 100644 --- a/components/date-picker2/__docs__/demo/format/index.tsx +++ b/components/date-picker2/__docs__/demo/format/index.tsx @@ -37,6 +37,9 @@ function App() {
    +
    + +
    ); } diff --git a/components/date-picker2/__tests__/index-spec.js b/components/date-picker2/__tests__/index-spec.js index 5ea28490ae..97b9ddd513 100644 --- a/components/date-picker2/__tests__/index-spec.js +++ b/components/date-picker2/__tests__/index-spec.js @@ -8,6 +8,8 @@ import dayjs from 'dayjs'; import co from 'co'; import moment from 'moment'; import DatePicker from '../index'; +import { ConfigProvider } from '@alifd/next'; +import en from '@alifd/next/lib/locale/en-us'; import Form from '../../form/index'; import Field from '../../field/index'; import { DATE_PICKER_MODE } from '../constant'; @@ -31,7 +33,7 @@ const render = element => { const container = document.createElement('div'); container.className = 'container'; document.body.appendChild(container); - ReactDOM.render(element, container, function() { + ReactDOM.render(element, container, function () { inc = this; }); return { @@ -137,6 +139,39 @@ describe('Picker', () => { assert.deepEqual(getStrValue(), ['', '2020-01-01 00:00:00']); }); + it('disable ok when input disabledDate', () => { + wrapper = mount( + v.isBefore(dayjs('2024-06-22 00:00:00'))} + defaultVisible + /> + ); + + changeInput('2024-06-19 11:12:13'); + assert.deepEqual( + wrapper.find('button.next-date-picker2-footer-ok').prop('disabled'), + true + ); + wrapper.unmount(); + + wrapper = mount( + v.isBefore(dayjs('2024-06-22 00:00:00'))} + defaultVisible + /> + ); + + changeInput('2024-06-19 11:12:13', 1); + assert.deepEqual( + wrapper.find('button.next-date-picker2-footer-ok').prop('disabled'), + true + ); + }); + it('showTime', () => { [DatePicker, RangePicker].forEach(Picker => { const defaultValue = @@ -400,9 +435,9 @@ describe('Picker', () => { .at(0) .getDOMNode() .getAttribute('title') === - dayjs() - .startOf('month') - .format('YYYY-MM-DD') + dayjs() + .startOf('month') + .format('YYYY-MM-DD') ); wrapper.unmount(); }); @@ -920,25 +955,25 @@ describe('Picker', () => { wrapper = null; } }); - // fix: https://github.com/alibaba-fusion/next/issues/3877 - it('should not select default endDate',()=>{ - const currentDate = dayjs(); - const currentDateStr = currentDate.format('YYYY-MM-DD'); - const disabledDate = function (date, mode) { - return currentDate.date() !== date.date(); - }; - wrapper = mount(); - clickDate(currentDateStr); - clickTime('12'); - clickTime('12', 'minute'); - clickTime('12', 'second'); - assert.deepEqual(getStrValue(), [`${currentDateStr} 12:12:12`, '']); - clickOk(); - clickTime('16'); - clickTime('16', 'minute'); - clickTime('35', 'second'); - clickOk(); - assert.deepEqual(getStrValue(), [`${currentDateStr} 12:12:12`, `${currentDateStr} 16:16:35`]); + // fix: https://github.com/alibaba-fusion/next/issues/3877 + it('should not select default endDate', () => { + const currentDate = dayjs(); + const currentDateStr = currentDate.format('YYYY-MM-DD'); + const disabledDate = function (date, mode) { + return currentDate.date() !== date.date(); + }; + wrapper = mount(); + clickDate(currentDateStr); + clickTime('12'); + clickTime('12', 'minute'); + clickTime('12', 'second'); + assert.deepEqual(getStrValue(), [`${currentDateStr} 12:12:12`, '']); + clickOk(); + clickTime('16'); + clickTime('16', 'minute'); + clickTime('35', 'second'); + clickOk(); + assert.deepEqual(getStrValue(), [`${currentDateStr} 12:12:12`, `${currentDateStr} 16:16:35`]); }); // https://github.com/alibaba-fusion/next/issues/2641 it('value controlled issue', () => { @@ -1005,7 +1040,7 @@ describe('Picker', () => { }); it('should support triggerType', () => { - return co(function*() { + return co(function* () { wrapper = render(); const btn = document.querySelector('.next-date-picker2 > div'); @@ -1015,11 +1050,11 @@ describe('Picker', () => { ReactTestUtils.Simulate.mouseLeave(btn); ReactTestUtils.Simulate.mouseEnter(document.querySelector('.next-calendar2-body')); - yield delay(300); + yield delay(200); assert(document.querySelector('.next-overlay-wrapper')); ReactTestUtils.Simulate.mouseLeave(document.querySelector('.next-calendar2-body')); - yield delay(500); + yield delay(1000); assert(!document.querySelector('.next-overlay-wrapper')); }); }); @@ -1104,7 +1139,7 @@ describe('Picker', () => { it('should reset to previous value when input a disableValue', () => { const currentDate = dayjs(defaultVal); // Disable all dates before currentDate: 2020-12-12 - const disabledDate = function(date, mode) { + const disabledDate = function (date, mode) { switch (mode) { case 'date': return date.valueOf() <= currentDate.valueOf(); @@ -1128,6 +1163,157 @@ describe('Picker', () => { wrapper = mount(); assert(wrapper.find('.next-icon-loading').length === 1); }); + + it('WeekPicker should format value correctly when date is 01-01', () => { + wrapper = mount() + assert(getStrValue() === '2021-52周'); + wrapper = mount() + assert(getStrValue() === '2021-52周'); + }); + + // fix https://github.com/alibaba-fusion/next/issues/4767 + it('should pass inputProps to trigger', () => { + mount( { + assert(typeof inputProps.onInputTypeChange === 'function'); + return
    test
    + }} />); + }); + + // fix https://github.com/alibaba-fusion/next/issues/4775 + it('RangePicker disabledDate method should return the correct panel mode', () => { + let panelMode = 'date'; + const disabledDate = function (date, mode) { + assert(panelMode === mode, `current panelMode is "${panelMode}", but got "${mode}"`); + return false; + }; + + wrapper = mount(); + findInput(1).simulate('click'); + assert(wrapper.find('.next-calendar2-table-date').length); + findDate('2021-01-31').simulate('mousemove'); + + panelMode = 'month'; + wrapper.find('.next-range-picker-left .next-calendar2-header-text-field button').at(1).simulate('click'); + assert(wrapper.find('.next-calendar2-table-month').length); + + panelMode = 'year'; + wrapper.find('.next-range-picker-left .next-calendar2-header-text-field button').simulate('click'); + assert(wrapper.find('.next-calendar2-table-year').length); + + panelMode = 'decade'; + wrapper.find('.next-range-picker-left .next-calendar2-header-text-field button').simulate('click'); + assert(wrapper.find('.next-calendar2-table-decade').length); + }) + + // fix https://github.com/alibaba-fusion/next/issues/4788 + it('The English translation does not comply with international standards', () => { + wrapper = mount( + + + + ); + assert(getStrValue() === 'Feb 2, 2020'); + assert(wrapper.find(`.next-calendar2-header-text-field`).text() === `Feb2020`); + }) + + // fix https://github.com/alibaba-fusion/next/issues/4790 + it('Unable to enter space to enter time', () => { + wrapper = mount( + + ); + changeInput('2020-11-11'); + findInput().simulate('keydown', { keyCode: KEYCODE.SPACE }); + assert(getStrValue(wrapper) === '2020-11-11 '); + wrapper.unmount(); + wrapper = mount( + + ); + changeInput('2020-11-11', 0); + findInput(0).simulate('keydown', { keyCode: KEYCODE.SPACE }); + assert(getStrValue(wrapper).join(',') === '2020-11-11 ,'); + }) + + // fix https://github.com/alibaba-fusion/next/issues/4896 + it('After entering a customized date format and pressing Enter, the value should not change', () => { + function App() { + const [value, setValue] = useState(''); + return ( + { + setValue(v), + assert( + v === dayjs('12/02/2020', 'DD/MM/YYYY').format('YYYY-MM-DD') + ); + }} + /> + ); + } + wrapper = mount(); + changeInput('12/02/2020'); + findInput().simulate('keydown', { keyCode: KEYCODE.ENTER }); + assert(getStrValue(wrapper) === '12/02/2020'); + }); + + // fix https://github.com/alibaba-fusion/next/issues/3006 + it('Support defaultValue & value for quarter', () => { + let defaultValueList = [ + [ + { in: '2021-Q2', out: '2021-Q2' }, + { in: '2021-Q3', out: '2021-Q3' }, + ], + [ + { in: '2021-4-1', out: '2021-Q2' }, + { in: '2021-8-1', out: '2021-Q3' }, + ], + ]; + defaultValueList.forEach(defaultValue => { + const inValue = defaultValue.map(item => item.in); + const outValue = defaultValue.map(item => item.out); + wrapper = mount(); + assert.deepEqual(getStrValue(), outValue); + }); + + defaultValueList = [ + { in: '2021-Q3', out: '2021-Q3' }, + { in: '2021-7-1', out: '2021-Q3' }, + ]; + defaultValueList.forEach(defaultValue => { + const { in: inValue, out: outValue } = defaultValue; + wrapper = mount(); + assert(getStrValue() === outValue); + }); + + let valueList = [ + [ + { in: '2021-Q2', out: '2021-Q2' }, + { in: '2021-Q3', out: '2021-Q3' }, + ], + [ + { in: '2021-4-1', out: '2021-Q2' }, + { in: '2021-8-1', out: '2021-Q3' }, + ], + ]; + valueList.forEach(value => { + const inValue = value.map(item => item.in); + const outValue = value.map(item => item.out); + wrapper = mount(); + assert.deepEqual(getStrValue(), outValue); + }); + + valueList = [ + { in: '2021-Q3', out: '2021-Q3' }, + { in: '2021-7-1', out: '2021-Q3' }, + ]; + valueList.forEach(value => { + const { in: inValue, out: outValue } = value; + wrapper = mount(); + assert(getStrValue() === outValue); + }); + }); }); }); diff --git a/components/date-picker2/index.jsx b/components/date-picker2/index.jsx index 8734b4a07d..ff262bfa31 100644 --- a/components/date-picker2/index.jsx +++ b/components/date-picker2/index.jsx @@ -6,7 +6,7 @@ import { DATE_PICKER_MODE } from './constant'; const { DATE, WEEK, MONTH, QUARTER, YEAR } = DATE_PICKER_MODE; const MODE2FORMAT = { [DATE]: 'YYYY-MM-DD', - [WEEK]: 'YYYY-wo', + [WEEK]: 'gggg-wo', [MONTH]: 'YYYY-MM', [QUARTER]: 'YYYY-[Q]Q', [YEAR]: 'YYYY', @@ -47,7 +47,10 @@ const transform = (props, deprecated) => { if (!newProps.format) { newProps.format = MODE2FORMAT[mode] + (newProps.showTime ? ' HH:mm:ss' : ''); - } + } else if (mode === WEEK && typeof newProps.format === 'string' && newProps.format.includes('YYYY')) { + // see https://github.com/alibaba-fusion/next/issues/3727 + newProps.format = newProps.format.replace('YYYY', 'gggg'); + } return newProps; }; diff --git a/components/date-picker2/panels/range-panel.jsx b/components/date-picker2/panels/range-panel.jsx index c41eadca28..ab86e56429 100644 --- a/components/date-picker2/panels/range-panel.jsx +++ b/components/date-picker2/panels/range-panel.jsx @@ -140,12 +140,12 @@ class RangePanel extends React.Component { disabledDate = v => { const { - mode, inputType, disabledDate, value: [begin, end], } = this.props; - + const { mode } = this.state; + const unit = mode2unit(mode); return ( diff --git a/components/date-picker2/picker.jsx b/components/date-picker2/picker.jsx index b8302a6278..234d6228af 100644 --- a/components/date-picker2/picker.jsx +++ b/components/date-picker2/picker.jsx @@ -14,6 +14,7 @@ import DateInput from './panels/date-input'; import DatePanel from './panels/date-panel'; import RangePanel from './panels/range-panel'; import FooterPanel from './panels/footer-panel'; +import { getValueWithDayjs } from '../util/func'; const { Popup } = Overlay; const { pickProps, pickOthers } = obj; @@ -175,7 +176,9 @@ class Picker extends React.Component { } if ('value' in props) { - const value = isRange ? checkRangeDate(props.value, state.inputType, disabled) : checkDate(props.value); + let value = getValueWithDayjs(props.value, format); + + value = isRange ? checkRangeDate(value, state.inputType, disabled) : checkDate(value); if (isValueChanged(value, state.preValue)) { newState = { @@ -220,12 +223,14 @@ class Picker extends React.Component { */ getInitValue = () => { const { props } = this; - const { type, value, defaultValue } = props; + const { type, value, defaultValue, format } = props; let val = type === DATE_PICKER_TYPE.RANGE ? [null, null] : null; val = 'value' in props ? value : 'defaultValue' in props ? defaultValue : val; + val = getValueWithDayjs(val, format); + return this.checkValue(val); }; @@ -390,7 +395,13 @@ class Picker extends React.Component { return false; } - return values.some(value => { + const valuesFiltered = values.filter(value => !!value); + + if (!valuesFiltered.length) { + return false; + } + + return valuesFiltered.some(value => { return disabledDate(value, panelMode); }); }; @@ -408,6 +419,20 @@ class Picker extends React.Component { this.handleChange(inputValue, 'KEYDOWN_ENTER'); break; } + case KEYCODE.SPACE: { + const { inputValue, isRange, inputType } = this.state; + this.onClick(); + if (isRange) { + const updatedInputValue = [...inputValue]; + updatedInputValue[inputType] = updatedInputValue[inputType] + ' '; + this.setState({ inputValue: updatedInputValue }) + } else { + this.setState({ + inputValue: inputValue + ' ' + }) + } + break; + } default: return; } @@ -420,7 +445,7 @@ class Picker extends React.Component { const isTemporary = showOk && !forceEvents.includes(eventType); // 面板收起时候,将值设置为确认值 - v = eventType === 'VISIBLE_CHANGE' ? value : this.checkValue(v, !isTemporary); + v = eventType === 'VISIBLE_CHANGE' ? value : this.checkValue(v, !isTemporary, format); this.setState({ curValue: v, @@ -441,7 +466,6 @@ class Picker extends React.Component { !isRange || ['CLICK_PRESET', 'VISIBLE_CHANGE', 'INPUT_CLEAR'].includes(eventType) || !this.shouldSwitchInput(v); - if (shouldHidePanel) { this.onVisibleChange(false); @@ -656,7 +680,7 @@ class Picker extends React.Component { inputProps.hasClear = false; } - const triggerNode = renderNode(trigger, ); + const triggerNode = renderNode(trigger, , inputProps); // 日期 const panelProps = { @@ -680,7 +704,7 @@ class Picker extends React.Component { ); // 底部节点 - const oKable = !!(isRange ? inputValue && inputValue[inputType] : inputValue); + const oKable = !!(!this.checkValueDisabled(inputValue) && (isRange ? inputValue && inputValue[inputType] : inputValue)); const shouldShowFooter = showOk || preset || extraFooterRender; const footerNode = shouldShowFooter ? ( diff --git a/components/demo-helper/index.tsx b/components/demo-helper/index.tsx index 0c631abd14..a7cc6699ed 100644 --- a/components/demo-helper/index.tsx +++ b/components/demo-helper/index.tsx @@ -36,7 +36,7 @@ export interface DemoFunctionDefineForObject { name?: string; label: string; value: unknown; - enum: Array<{ label: string; value: string }>; + enum: Array<{ label: string; value: string | boolean }>; } const COL = '{Col}'; @@ -127,7 +127,7 @@ function convertObjectToArray(demoFunction: Record { - return e.value; + return String(e.value); }), }); }); @@ -169,7 +169,7 @@ interface BaseProps { switchVisible?: (demoIndex: string) => unknown; } -interface DemoProps extends Omit { +export interface DemoProps extends Omit { parentDisplayName?: string; defaultBackground?: 'dark' | 'light'; title: string; diff --git a/components/dialog/__docs__/adaptor/index.jsx b/components/dialog/__docs__/adaptor/index.jsx deleted file mode 100644 index 7cc21652a7..0000000000 --- a/components/dialog/__docs__/adaptor/index.jsx +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react'; -import { Types } from '@alifd/adaptor-helper'; -import { Dialog, Message, Button } from '@alifd/next'; -import locale from '../../../locale/en-us'; - - -export default { - name: 'Dialog', - editor: () => ({ - props: [{ - name: 'level', - type: Types.enum, - options: ['normal', 'alert', 'confirm'], - default: 'normal' - }, { - name: 'footerAlign', - label: 'Button Position', - type: Types.enum, - options: ['left', 'right', 'center'], - default: 'right' - }, { - name: 'okButtonPosition', - label: 'Button Order', - type: Types.enum, - options: ['left', 'right'], - default: 'left' - }, { - name: 'mask', - type: Types.bool, - default: false - }, { - name: 'width', - type: Types.number, - default: 400 - }, { - name: 'height', - type: Types.number, - default: 160 - }, { - name: 'title', - type: Types.string, - default: 'Welcome to Alibaba.com' - }, { - name: 'confirmButtonText', - label: 'Confirm Text', - type: Types.string, - default: 'OK' - }, { - name: 'cancelButtonText', - label: 'Cancel Text', - type: Types.string, - default: 'Cancel' - }], - data: { - default: 'Start your business here by searching a popular product' - } - }), - adaptor: ({ level, footerAlign, okButtonPosition, mask, width, height, title, style, className, confirmButtonText, cancelButtonText, data, ...others}) => { - const dialogStyle = { - position: mask ? 'absolute' : 'relative', - width: width, - zIndex: 1, - maxWidth: 'none', - ...(mask ? { - left: 20, - top: 20, - } : style), - }; - - const props = { - ...(mask ? {} : {...others }), - className: level === 'normal' ? className : `${className || ''} next-dialog-quick`, - style: dialogStyle, - footerAlign: footerAlign, - footerActions: okButtonPosition === 'left' ? ['ok', 'cancel'] : ['cancel', 'ok'], - locale: locale.Dialog, - height: `${height}px`, - footer: okButtonPosition === 'left' ? [, ] - : [, ], - }; - - let dialog; - switch(level) { - case 'alert': - dialog = ( - - - {data} - - - ); - break; - case 'confirm': - dialog = ( - - - {data} - - - ); - break; - default: - dialog = {data} - break; - } - - return mask ? ( -
    -
    - {dialog} -
    - ) : dialog; - }, - content: () => ({ - options: [{ - name: 'title', - options: ['show', 'hide'], - default: 'show', - }, { - name: 'overlay', - options: ['show', 'hide'], - default: 'hide', - }, { - name: 'footerAlign', - options: ['left', 'center', 'right'], - default: 'right' - }, { - name: 'okButtonPosition', - options: ['left', 'right'], - default: 'right' - }], - transform: (props, { title, overlay, footerAlign, okButtonPosition }) => { - return { - ...props, - title: title === 'hide' ? '' : title, - mask: overlay === 'show', - footerAlign, - okButtonPosition, - }; - } - }) -}; diff --git a/components/dialog/__docs__/adaptor/index.tsx b/components/dialog/__docs__/adaptor/index.tsx new file mode 100644 index 0000000000..9709dec41b --- /dev/null +++ b/components/dialog/__docs__/adaptor/index.tsx @@ -0,0 +1,229 @@ +import React, { type ReactNode } from 'react'; +import { Types } from '@alifd/adaptor-helper'; +import { Dialog, Message, Button } from '@alifd/next'; +import locale from '../../../locale/en-us'; +import type { InnerProps } from '../../types'; + +type AdaptorProps = InnerProps & { + confirmButtonText?: ReactNode; + cancelButtonText?: ReactNode; + data?: ReactNode; + mask?: boolean; + okButtonPosition?: 'left' | 'right'; + level?: 'normal' | 'alert' | 'confirm'; + width?: number; +}; + +export default { + name: 'Dialog', + editor: () => ({ + props: [ + { + name: 'level', + type: Types.enum, + options: ['normal', 'alert', 'confirm'], + default: 'normal', + }, + { + name: 'footerAlign', + label: 'Button Position', + type: Types.enum, + options: ['left', 'right', 'center'], + default: 'right', + }, + { + name: 'okButtonPosition', + label: 'Button Order', + type: Types.enum, + options: ['left', 'right'], + default: 'left', + }, + { + name: 'mask', + type: Types.bool, + default: false, + }, + { + name: 'width', + type: Types.number, + default: 400, + }, + { + name: 'height', + type: Types.number, + default: 160, + }, + { + name: 'title', + type: Types.string, + default: 'Welcome to Alibaba.com', + }, + { + name: 'confirmButtonText', + label: 'Confirm Text', + type: Types.string, + default: 'OK', + }, + { + name: 'cancelButtonText', + label: 'Cancel Text', + type: Types.string, + default: 'Cancel', + }, + ], + data: { + default: 'Start your business here by searching a popular product', + }, + }), + adaptor: ({ + level, + footerAlign, + okButtonPosition, + mask, + width, + height, + title, + style, + className, + confirmButtonText, + cancelButtonText, + data, + ...others + }: AdaptorProps) => { + const dialogStyle: InnerProps['style'] = { + position: mask ? 'absolute' : 'relative', + width: width, + zIndex: 1, + maxWidth: 'none', + ...(mask + ? { + left: 20, + top: 20, + } + : style), + }; + + const props: InnerProps = { + ...(mask ? {} : { ...others }), + className: level === 'normal' ? className : `${className || ''} next-dialog-quick`, + style: dialogStyle, + footerAlign: footerAlign, + footerActions: okButtonPosition === 'left' ? ['ok', 'cancel'] : ['cancel', 'ok'], + locale: locale.Dialog, + height: `${height}px`, + footer: + okButtonPosition === 'left' + ? [ + , + , + ] + : [ + , + , + ], + }; + + let dialog; + switch (level) { + case 'alert': + dialog = ( + + + {data} + + + ); + break; + case 'confirm': + dialog = ( + + + {data} + + + ); + break; + default: + dialog = ( + + {data} + + ); + break; + } + + return mask ? ( +
    +
    + {dialog} +
    + ) : ( + dialog + ); + }, + content: () => ({ + options: [ + { + name: 'title', + options: ['show', 'hide'], + default: 'show', + }, + { + name: 'overlay', + options: ['show', 'hide'], + default: 'hide', + }, + { + name: 'footerAlign', + options: ['left', 'center', 'right'], + default: 'right', + }, + { + name: 'okButtonPosition', + options: ['left', 'right'], + default: 'right', + }, + ], + transform: ( + props: AdaptorProps, + { title, overlay, footerAlign, okButtonPosition }: AdaptorProps & { overlay?: string } + ) => { + return { + ...props, + title: title === 'hide' ? '' : title, + mask: overlay === 'show', + footerAlign, + okButtonPosition, + }; + }, + }), +}; diff --git a/components/dialog/__docs__/demo/basic/index.tsx b/components/dialog/__docs__/demo/basic/index.tsx index 9a06c65d1e..6fbd00babb 100644 --- a/components/dialog/__docs__/demo/basic/index.tsx +++ b/components/dialog/__docs__/demo/basic/index.tsx @@ -1,6 +1,7 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Dialog } from '@alifd/next'; +import type { DialogProps } from '@alifd/next/types/dialog'; class Demo extends React.Component { state = { @@ -13,8 +14,15 @@ class Demo extends React.Component { }); }; - onClose = e => { - console.log(e.triggerType); + onOk: DialogProps['onOk'] = e => { + console.log(e); + this.setState({ + visible: false, + }); + }; + + onClose: DialogProps['onClose'] = (triggerType, e) => { + console.log(triggerType, e); this.setState({ visible: false, }); @@ -30,7 +38,7 @@ class Demo extends React.Component { v2 title="Welcome to Alibaba.com" visible={this.state.visible} - onOk={this.onClose} + onOk={this.onOk} onClose={this.onClose} >

    Start your business here by searching a popular product

    diff --git a/components/dialog/__docs__/demo/customize-footer/index.tsx b/components/dialog/__docs__/demo/customize-footer/index.tsx index 46d7464f7c..6eb4432dba 100644 --- a/components/dialog/__docs__/demo/customize-footer/index.tsx +++ b/components/dialog/__docs__/demo/customize-footer/index.tsx @@ -2,8 +2,14 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Dialog } from '@alifd/next'; +interface DemoState { + visible?: boolean; + fullyCustomizedVisible?: boolean; + textCustomizedVisible?: boolean; +} + class Demo extends React.Component { - state = { + state: DemoState = { visible: false, }; diff --git a/components/dialog/__docs__/demo/draggable/index.tsx b/components/dialog/__docs__/demo/draggable/index.tsx index 75730af072..95a245cb34 100644 --- a/components/dialog/__docs__/demo/draggable/index.tsx +++ b/components/dialog/__docs__/demo/draggable/index.tsx @@ -1,7 +1,8 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Button, Dialog, Box } from '@alifd/next'; -import Draggable from 'react-draggable'; +import { Button, Dialog } from '@alifd/next'; +import Draggable, { type DraggableCoreProps } from 'react-draggable'; +import type { DialogProps } from '@alifd/next/types/dialog'; class App extends React.Component { state = { @@ -10,7 +11,7 @@ class App extends React.Component { bounds: { left: 0, top: 100, bottom: 0, right: 0 }, }; - draggleRef = React.createRef(); + draggleRef = React.createRef(); showModal = () => { this.setState({ @@ -18,14 +19,21 @@ class App extends React.Component { }); }; - handleCancel = e => { + handleCancel: DialogProps['onOk'] = () => { this.setState({ visible: false, }); }; - onStart = (event, uiData) => { + handleClose: DialogProps['onClose'] = () => { + this.setState({ + visible: false, + }); + }; + + onStart: DraggableCoreProps['onStart'] = (event, uiData) => { const { clientWidth, clientHeight } = window.document.documentElement; + if (!this.draggleRef.current) return; const targetRect = this.draggleRef.current.getBoundingClientRect(); this.setState({ bounds: { @@ -37,7 +45,7 @@ class App extends React.Component { }); }; - toogleDisabled(disabled) { + toogleDisabled(disabled: boolean) { if (disabled === this.state.disabled) { return; } @@ -61,7 +69,7 @@ class App extends React.Component { } visible={visible} onOk={this.handleCancel} - onClose={this.handleCancel} + onClose={this.handleClose} v2 cache dialogRender={modal => ( diff --git a/components/dialog/__docs__/demo/footer/index.tsx b/components/dialog/__docs__/demo/footer/index.tsx index f36192b770..f97c1a27f8 100644 --- a/components/dialog/__docs__/demo/footer/index.tsx +++ b/components/dialog/__docs__/demo/footer/index.tsx @@ -1,9 +1,17 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Radio, Dialog } from '@alifd/next'; +import type { DialogProps } from '@alifd/next/types/dialog'; + +interface DemoState { + visible?: boolean; + footerActions: NonNullable; + footerAlign?: DialogProps['footerAlign']; + loading?: boolean; +} class Demo extends React.Component { - state = { + state: DemoState = { visible: false, footerActions: ['ok', 'cancel'], footerAlign: 'right', @@ -22,19 +30,19 @@ class Demo extends React.Component { }); }; - toggleFooterActions = footerActionsStr => { + toggleFooterActions = (footerActionsStr: string) => { this.setState({ footerActions: footerActionsStr.split(','), }); }; - toggleFooterAlign = footerAlign => { + toggleFooterAlign = (footerAlign: string) => { this.setState({ footerAlign, }); }; - toggleOkLoader = loading => { + toggleOkLoader = (loading: boolean) => { this.setState({ loading, }); diff --git a/components/dialog/__docs__/demo/large-content/index.tsx b/components/dialog/__docs__/demo/large-content/index.tsx index b7cec0c0a6..605b1c8b25 100644 --- a/components/dialog/__docs__/demo/large-content/index.tsx +++ b/components/dialog/__docs__/demo/large-content/index.tsx @@ -2,10 +2,6 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Switch, Button, Dialog, Icon } from '@alifd/next'; -const largeContent = new Array(60) - .fill() - .map((_, index) =>

    Start your business here by searching a popular product

    ); - class Demo extends React.Component { state = { visible: false, @@ -33,7 +29,7 @@ class Demo extends React.Component { }; render() { - const { visible, overflowScroll, closeOnMaskClick, count } = this.state; + const { visible, overflowScroll, count } = this.state; return (
    @@ -60,7 +56,7 @@ class Demo extends React.Component { onClose={this.onClose} > {Array(count) - .fill() + .fill(0) .map((_, index) => (

    a long long content here diff --git a/components/dialog/__docs__/demo/quick/index.tsx b/components/dialog/__docs__/demo/quick/index.tsx index d53724d627..f6d49ef49a 100644 --- a/components/dialog/__docs__/demo/quick/index.tsx +++ b/components/dialog/__docs__/demo/quick/index.tsx @@ -21,7 +21,7 @@ const popupError = () => { }; const popupShow = () => { - const dialog = Dialog.show({ + Dialog.show({ v2: true, title: 'Custom', content: 'custom content custom content...', @@ -29,7 +29,7 @@ const popupShow = () => { }; ReactDOM.render( - + diff --git a/components/dialog/__docs__/index.en-us.md b/components/dialog/__docs__/index.en-us.md index 7878779718..9b13f8e9e3 100644 --- a/components/dialog/__docs__/index.en-us.md +++ b/components/dialog/__docs__/index.en-us.md @@ -19,7 +19,7 @@ version 1.25 add api `v2` to support open new version Dialog, feature as list: feature: -- use css (not js) to compute position, will easier +- use css (not js) to compute position, will easier - add `closeIcon` to controll icon display - add `width` to fix width of dialog, or you can set `width=auto` to follow content width - add `dialogRender` use with `react-draggable` support draggable @@ -30,47 +30,81 @@ changes: - deprecated `minMargin` , use `top` `bottom` insteaded - deprecated `isFullScreen` , use `overflowScroll` insteaded - ## API ### Dialog -| Param | Descripiton | Type | Default Value | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------- | -| visible | whether is visible | Boolean | false | -| title | title of dialog | ReactNode | - | -| children | content of dialog | ReactNode | - | -| footer | bottom content, if yout set to false, will not display | Boolean/ReactNode | [<Button type="primary">Ok</Button>, <Button>Cancel</Button>] | -| footerAlign | alignment of footer

    **option**:
    'left', 'center', 'right' | Enum | 'right' | -| footerActions | specify whether the ok button and the cancel button exist and how they are arranged

    **option**:
    ['ok', 'cancel'] (The ok and the cancel buttons exist at the same time, and the ok button is on the left)
    ['cancel', 'ok'] (The ok and the cancel buttons exist at the same time, and the cancel button is on the left)
    ['ok'] (only ok button exists)
    ['cancel'] (only cancel button exists) | Array | ['ok', 'cancel'] | -| onOk | callback function triggered when the ok button is clicked

    **signatures**:
    Function(event: Object) => void
    **params**:
    _event_: {Object} clicked event | Function | () => {} | -| onCancel | callback function triggered when the cancel button is clicked

    **signatures**:
    Function(event: Object) => void
    **params**:
    _event_: {Object} clicked event | Function | () => {} | -| okProps | properties of the ok button | Object | {} | -| cancelProps | properties of the cancel button | Object | {} | -| closeable | [deprecated]controls how the dialog is closed. The value can be either a String or Boolean, where the string consists of the following values:
    **close** clicking the close button can close the dialog
    **mask** clicking the mask can close the dialog
    **esc** pressing the esc key can close the dialog
    such as 'close' or 'close,esc,mask'
    If set to true, all of the above close methods take effect
    If set to false, all of the above close methods will fail | String/Boolean | 'esc,close' | -| v2 | for v2 version| Boolean | - | | -| width | [v2] width of dialog | String/Number | - | 1.25 | -| top | [v2] margin top align, default 100, if set centered=true default will be 40 | Number | - | 1.25 | -| bottom | [v2] margin bottom align | Number | 40 | 1.25 | -| closeIcon | [v2] custom close icon | ReactNode | - | 1.25 | -| centered | [v2] dialog centened | Boolean | - | 1.25 | -| overflowScroll | [v2] dialog height overflow browser view, dialog will show scrollbar | Boolean | true | 1.25 | -| closeable | [deprecated]controls how the dialog is closed. The value can be either a String or Boolean, where the string consists of the following values:
    **close** clicking the close button can close the dialog
    **mask** clicking the mask can close the dialog
    **esc** pressing the esc key can close the dialog
    such as 'close' or 'close,esc,mask'
    If set to true, all of the above close methods take effect
    If set to false, all of the above close methods will fail | String/Boolean | 'esc,close' | -| closeMode | [recommand]controls how the dialog is closed. The value can be either a String or Array:
    **close** clicking the close button can close the dialog
    **mask** clicking the mask can close the dialog
    **esc** pressing the esc key can close the dialog
    for example 'close' or ['close','esc','mask'] | Array<Enum>/Enum | - | -| onClose | callback function triggered when the dialog closes

    **signatures**:
    Function(trigger: String, event: Object) => void
    **params**:
    _trigger_: {String} behavior triggered closed
    _event_: {Object} closed event | Function | () => {} | -| afterClose | callback function triggered after the dialog closed, if enabel animation, it will trigger after the animation ends.

    **signatures**:
    Function() => void | Function | () => {} | -| hasMask | whether to has mask | Boolean | true | -| animation | open and close animation class name | Object/Boolean | { in: 'fadeInDown', out: 'fadeOutUp' } | -| autoFocus | whether to focus the element in the dialog automatically when the dialog is opened | Boolean | false | -| align | alignment of dialog, @see overlay docs for detail | String/Boolean | 'cc cc' | -| isFullScreen | when the height of the dialog exceeds the viewport height of the browser, whether to display all content of dialog or display scrollbars to ensure that the dialog is fully displayed in the viewport. This property is only effective when the dialog is vertically horizontally centered, that is, align is set to 'cc cc' | Boolean | false | -| shouldUpdatePosition | whether to update the dialog position when the dialog is rerendered. It is generally used to ensure the original alignment after the height of the dialog changes. | Boolean | false | -| minMargin | the minimum distance between the dialog box at the top and bottom of the browser, it will not work if set align to 'cc cc' and set isFullScreen to true | Number | 40 | -| overlayProps | properties of Overlay | Object | {} | -| height | height style attribute for dialog | String | - | -| v2 | v2 version dialog | Boolean | - | | -| width | dialog width (used while v2=true) | Number | - | | - +| Param | Description | Type | Default Value | Required | Supported Version | +| -------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------- | -------- | ----------------- | +| visible | Whether to show | boolean | false | | - | +| title | Title | React.ReactNode | - | | - | +| children | Content | React.ReactNode | - | | - | +| footer | Footer content, set to false to hide | boolean \| React.ReactNode | - | | - | +| footerAlign | Footer button alignment | 'left' \| 'center' \| 'right' | 'right' | | - | +| footerActions | Specify whether to exist and how to arrange the confirm and cancel buttons | Array\<'ok' \| 'cancel'> | ['ok', 'cancel'] | | - | +| cache | Whether to retain the child node when hiding | boolean | false | | 1.23 | +| onOk | Callback function when the confirm button is clicked | (event: React.MouseEvent) => void | - | | - | +| onCancel | Callback function when the cancel button is clicked | (event: React.MouseEvent) => void | - | | - | +| okProps | Properties for the confirm button | ButtonProps | - | | - | +| cancelProps | Properties for the cancel button | ButtonProps | - | | - | +| closeable | [Deprecated] Control the way the dialog is closed | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | 'esc,close' | | - | +| closeMode | Control the way the dialog is closed | CloseMode[] \| CloseMode | - | | 1.21 | +| onClose | Callback function when the dialog is closed | (trigger: string, event: React.MouseEvent \| KeyboardEvent) => void | - | | - | +| afterClose | Callback function after the dialog is closed, if there is an animation, it will be triggered after the animation ends | () => void | - | | - | +| hasMask | Whether to show the mask | boolean | true | | - | +| animation | Animation playback method when showing and hiding | Record\<'in' \| 'out', string> \| false | \{ in: 'fadeInUp', out: 'fadeOutUp' \} | | - | +| autoFocus | Whether to automatically obtain focus when the dialog is displayed | boolean | false | | - | +| align | [v2 Deprecated] Dialog alignment, see Overlay documentation | string \| boolean | - | | - | +| isFullScreen | [v2 Deprecated] Whether to display all content instead of appearing a scroll bar to ensure that the dialog is displayed completely in the browser viewport, and this attribute only takes effect when the dialog is vertically and horizontally centered, that is, when align is set to 'cc cc' | boolean | - | | - | +| shouldUpdatePosition | [v2 Deprecated] Whether to update the dialog position immediately when the dialog is re | boolean | - | | - | +| minMargin | [v2 Deprecated] The minimum spacing between the dialog and the top and bottom of the browser, and the isFullScreen property is set to true and the align property is set to 'cc cc' is not effective | number | 40 | | - | +| overlayProps | Properties for the overlay component | OverlayProps | - | | - | +| locale | Customized internationalized text | Partial\<{
    ok: string;
    cancel: string;
    }> | - | | - | +| height | Dialog height style | string \| number | - | | - | +| width | Dialog width | string \| number | - | | 1.25 | +| popupContainer | Custom mount position | string \| HTMLElement \| ((target?: HTMLElement) => HTMLElement) | - | | - | +| v2 | Enable v2 version | false \| undefined | false | | - | +| noPadding | Remove body spacing | boolean | false | | 1.26 | +| closeIcon | Customize the close button icon | React.ReactNode | - | | 1.25 | + +### Dialog V2 + +| Param | Description | Type | Default Value | Required | Supported Version | +| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------- | -------- | ----------------- | +| visible | Whether to show | boolean | false | | - | +| title | Title | React.ReactNode | - | | - | +| children | Content | React.ReactNode | - | | - | +| footer | Footer content, set to false to hide | boolean \| React.ReactNode | - | | - | +| footerAlign | Footer button alignment | 'left' \| 'center' \| 'right' | 'right' | | - | +| footerActions | Specify whether to exist and how to arrange the confirm and cancel buttons | Array\<'ok' \| 'cancel'> | ['ok', 'cancel'] | | - | +| cache | Whether to retain the child node when hiding | boolean | false | | 1.23 | +| onOk | Callback function when the confirm button is clicked | (event: React.MouseEvent) => void | - | | - | +| onCancel | Callback function when the cancel button is clicked | (event: React.MouseEvent) => void | - | | - | +| okProps | Properties for the confirm button | ButtonProps | - | | - | +| cancelProps | Properties for the cancel button | ButtonProps | - | | - | +| closeMode | Control the way the dialog is closed | CloseMode[] \| CloseMode | - | | 1.21 | +| onClose | Callback function when the dialog is closed | (trigger: string, event: React.MouseEvent \| KeyboardEvent) => void | - | | - | +| afterClose | Callback function after the dialog is closed, if there is an animation, it will be triggered after the animation ends | () => void | - | | - | +| hasMask | Whether to show the mask | boolean | true | | - | +| animation | Animation playback method when showing and hiding | Record\<'in' \| 'out', string> \| false | \{ in: 'fadeInUp', out: 'fadeOutUp' \} | | - | +| autoFocus | Whether to automatically obtain focus when the dialog is displayed | boolean | false | | - | +| isFullScreen | [v2 Deprecated] Whether to display all content instead of appearing a scroll bar to ensure that the dialog is displayed completely in the browser viewport, and this attribute only takes effect when the dialog is vertically and horizontally centered, that is, when align is set to 'cc cc' | boolean | - | | - | +| minMargin | [v2 Deprecated] The minimum spacing between the dialog and the top and bottom of the browser, and the isFullScreen property is set to true and the align property is set to 'cc cc' is not effective | number | 40 | | - | +| overlayProps | Properties for the overlay component | OverlayProps | - | | - | +| locale | Customized internationalized text | Partial\<{
    ok: string;
    cancel: string;
    }> | - | | - | +| height | Dialog height style | string \| number | - | | - | +| popupContainer | Custom mount position | string \| HTMLElement \| ((target?: HTMLElement) => HTMLElement) | - | | - | +| v2 | Enable v2 version | true | false | | - | +| closeIcon | Customize the close button icon | React.ReactNode | - | | 1.25 | +| width | Dialog width | string \| number | - | | 1.25 | +| noPadding | Remove body spacing | boolean | false | | 1.26 | +| top | [v2] Dialog top margin, default 100, when centered=true, default 40 | number | - | | 1.25 | +| bottom | [v2] Dialog bottom margin | number | 40 | | 1.25 | +| overflowScroll | [v2] Whether to display a scroll bar when the dialog height exceeds the browser viewport height | boolean | - | | 1.25 | +| centered | [v2] Dialog center alignment | boolean | - | | 1.25 | +| dialogRender | [v2] Custom rendering the dialog | (modal: React.ReactNode) => React.ReactNode | - | | - | +| wrapperClassName | [v2] The className of the outer wrapper | string | - | | 1.26 | +| wrapperStyle | [v2] The style of the outer wrapper | React.CSSProperties | - | | 1.26 | @@ -78,24 +112,24 @@ changes: The following only list common properties that config can pass, and other properties of the Dialog can also be passed in. -| Param | Descripiton | Type | Default Value | -| :----------- | :---------------- | :-------- | :------- | -| title | title of dialog | ReactNode | '' | -| content | content of dialog | ReactNode | '' | -| onOk | callback function triggered when the ok button is clicked | Function | () => {} | -| onCancel | callback function triggered when the cancel button is clicked | Function | () => {} | -| messageProps | properties of Message | Object | {} | +| Param | Descripiton | Type | Default Value | +| :----------- | :------------------------------------------------------------ | :-------- | :------------ | +| title | title of dialog | ReactNode | '' | +| content | content of dialog | ReactNode | '' | +| onOk | callback function triggered when the ok button is clicked | Function | () => {} | +| onCancel | callback function triggered when the cancel button is clicked | Function | () => {} | +| messageProps | properties of Message | Object | {} | ### Dialog.show The following only list common properties that config can pass, and other properties of the Dialog can also be passed in. -| Param | Descripiton | Type | Default Value | -| :------- | :-------------- | :-------- | :------- | -| title | title of dialog | ReactNode | '' | -| content | content of dialog | ReactNode | '' | -| onOk | callback function triggered when the ok button is clicked | Function | () => {} | -| onCancel | callback function triggered when the cancel button is clicked | Function | () => {} | +| Param | Descripiton | Type | Default Value | +| :------- | :------------------------------------------------------------ | :-------- | :------------ | +| title | title of dialog | ReactNode | '' | +| content | content of dialog | ReactNode | '' | +| onOk | callback function triggered when the ok button is clicked | Function | () => {} | +| onCancel | callback function triggered when the cancel button is clicked | Function | () => {} | @@ -111,8 +145,8 @@ The Dialog uses JS for positioning by default. When the content is too long, JS ## ARIA and Keyboard -| Keyboard | Descripiton | -| :-------- | :--------------------------------------- | -| esc | pressing ESC will close dialog | -| tab | focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | -| shift+tab | back focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | +| Keyboard | Descripiton | +| :-------- | :---------------------------------------------------------------------------------------------------------- | +| esc | pressing ESC will close dialog | +| tab | focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | +| shift+tab | back focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | diff --git a/components/dialog/__docs__/index.md b/components/dialog/__docs__/index.md index bedc4bd910..a7e7ec4207 100644 --- a/components/dialog/__docs__/index.md +++ b/components/dialog/__docs__/index.md @@ -21,10 +21,10 @@ - 位置不再通过 js 计算,通过 css 完成,响应式性能更好 - 新增 `closeIcon` 可定制关闭按钮 icon -- 新增 `width` 固定弹窗宽度,默认值为520px, 或者设置 auto 跟随内容变化。 +- 新增 `width` 固定弹窗宽度,默认值为 520px, 或者设置 auto 跟随内容变化。 - 新增 `dialogRender` 配合 `react-draggable` 以支持拖拽弹窗 -API变化: +API 变化: - 移除了 `align` `shouldUpdatePosition`, Dialog 会自动调整位置 - 移除了 `minMargin` , 改用 `top` `bottom` @@ -34,42 +34,77 @@ API变化: ### Dialog -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | --------------------------------------------------------------------------------- | -------- | -| visible | 是否显示 | Boolean | false | | -| title | 标题 | ReactNode | - | | -| children | 内容 | ReactNode | - | | -| footer | 底部内容,设置为 false,则不进行显示 | Boolean/ReactNode | [<Button type="primary">确定</Button>, <Button>取消</Button>] | | -| footerAlign | 底部按钮的对齐方式

    **可选值**:
    'left', 'center', 'right' | Enum | 'right' | | -| footerActions | 指定确定按钮和取消按钮是否存在以及如何排列,

    **可选值**:
    ['ok', 'cancel'](确认取消按钮同时存在,确认按钮在左)
    ['cancel', 'ok'](确认取消按钮同时存在,确认按钮在右)
    ['ok'](只存在确认按钮)
    ['cancel'](只存在取消按钮) | Array | ['ok', 'cancel'] | | -| onOk | 在点击确定按钮时触发的回调函数

    **签名**:
    Function(event: Object) => void
    **参数**:
    _event_: {Object} 点击事件对象 | Function | () => {} | | -| onCancel | 在点击取消/关闭按钮时触发的回调函数

    **签名**:
    Function(event: Object) => void
    **参数**:
    _event_: {Object} 点击事件对象, event.triggerType=esc | closeIcon 可区分点击来源 | Function | () => {} | -| okProps | 应用于确定按钮的属性对象 | Object | {} | | -| cancelProps | 应用于取消按钮的属性对象 | Object | {} | | -| closeMode | [推荐]1.21.x 支持控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举:
    **close** 表示点击关闭按钮可以关闭对话框
    **mask** 表示点击遮罩区域可以关闭对话框
    **esc** 表示按下 esc 键可以关闭对话框
    如 'close' 或 ['close','esc','mask'], \[] | Array<Enum>/Enum | - | 1.21 | -| cache | 隐藏时是否保留子节点,不销毁 (低版本通过 overlayProps 实现) | Boolean | false | 1.23 | -| afterClose | 对话框关闭后触发的回调函数, 如果有动画,则在动画结束后触发

    **签名**:
    Function() => void | Function | () => {} | | -| hasMask | 是否显示遮罩 | Boolean | true | | -| animation | 显示隐藏时动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画。 请参考 Animate 组件的文档获取可用的动画名 | Object/Boolean | { in: 'expandInDown', out: 'expandOutUp' } | | -| autoFocus | 对话框弹出时是否自动获得焦点 | Boolean | false | | -| overlayProps | [v2废弃] 透传到弹层组件的属性对象 | Object | {} | | -| popupContainer | 自定义弹窗挂载位置 | any | - | | -| height | 对话框的高度样式属性 | String/Number | - | | -| v2 | 开启 v2 版本弹窗 | Boolean | - | | -| width | [v2] 弹窗宽度 | String/Number | - | 1.25 | -| top | [v2] 弹窗上边距。默认 100,设置 centered=true 后默认 40 | Number | - | 1.25 | -| bottom | [v2] 弹窗下边距 | Number | 40 | 1.25 | -| closeIcon | [v2] 定制关闭按钮 icon | ReactNode | - | 1.25 | -| centered | [v2] 弹窗居中对齐 | Boolean | false | 1.25 | -| overflowScroll | [v2] 对话框高度超过浏览器视口高度时,对话框是否展示滚动条。关闭此功后对话框会随高度撑开页面。 | Boolean | true | 1.25 | -| wrapperClassName | [v2] 最外包裹层 className | String | - | 1.26 | -| closeable | [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成:
    **close** 表示点击关闭按钮可以关闭对话框
    **mask** 表示点击遮罩区域可以关闭对话框
    **esc** 表示按下 esc 键可以关闭对话框
    如 'close' 或 'close,esc,mask'
    如果设置为 true,则以上关闭方式全部生效
    如果设置为 false,则以上关闭方式全部失效 | String/Boolean | 'esc,close' | | -| onClose | 点击对话框关闭按钮时触发的回调函数

    **签名**:
    Function(trigger: String, event: Object) => void
    **参数**:
    _trigger_: {String} 关闭触发行为的描述字符串
    _event_: {Object} 关闭时事件对象 | Function | () => {} | | -| align | [v2废弃] 对话框对齐方式, 具体见Overlay文档 | String/Boolean | 'cc cc' | | -| isFullScreen | [v2废弃] 是否撑开页面。 v2 改用 overflowScroll | Boolean | false | | -| shouldUpdatePosition | [v2废弃] 是否在对话框重新渲染时及时更新对话框位置,一般用于对话框高度变化后依然能保证原来的对齐方式 | Boolean | false | | -| minMargin | [v2废弃] 对话框距离浏览器顶部和底部的最小间距,align 被设置为 'cc cc' 并且 isFullScreen 被设置为 true 时不生效 | Number | 40 | | -| noPadding | 去除body内间距 | Boolean | false | 1.26 | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------- | -------- | -------- | +| visible | 是否显示 | boolean | false | | - | +| title | 标题 | React.ReactNode | - | | - | +| children | 内容 | React.ReactNode | - | | - | +| footer | 底部内容,设置为 false,则不进行显示 | boolean \| React.ReactNode | - | | - | +| footerAlign | 底部按钮的对齐方式 | 'left' \| 'center' \| 'right' | 'right' | | - | +| footerActions | 指定确定按钮和取消按钮是否存在以及如何排列 | Array\<'ok' \| 'cancel'> | ['ok', 'cancel'] | | - | +| cache | 隐藏时是否保留子节点,不销毁 | boolean | false | | 1.23 | +| onOk | 在点击确定按钮时触发的回调函数 | (event: React.MouseEvent) => void | - | | - | +| onCancel | 在点击取消按钮时触发的回调函数 | (event: React.MouseEvent) => void | - | | - | +| okProps | 应用于确定按钮的属性对象 | ButtonProps | - | | - | +| cancelProps | 应用于取消按钮的属性对象 | ButtonProps | - | | - | +| closeable | [废弃] 同 closeMode, 控制对话框关闭的方式 | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | 'esc,close' | | - | +| closeMode | [推荐] 控制对话框关闭的方式 | CloseMode[] \| CloseMode | - | | 1.21 | +| onClose | 对话框关闭时触发的回调函数 | (trigger: string, event: React.MouseEvent \| KeyboardEvent) => void | - | | - | +| afterClose | 对话框关闭后触发的回调函数,如果有动画,则在动画结束后触发 | () => void | - | | - | +| hasMask | 是否显示遮罩 | boolean | true | | - | +| animation | 显示隐藏时动画的播放方式 | Record\<'in' \| 'out', string> \| false | \{ in: 'fadeInUp', out: 'fadeOutUp' \} | | - | +| autoFocus | 对话框弹出时是否自动获得焦点 | boolean | false | | - | +| align | [v2 废弃] 对话框对齐方式,具体见 Overlay 文档 | string \| boolean | - | | - | +| isFullScreen | [v2 废弃] 当对话框高度超过浏览器视口高度时,是否显示所有内容而不是出现滚动条以保证对话框完整显示在浏览器视口内,该属性仅在对话框垂直水平居中时生效,即 align 被设置为 'cc cc' 时 | boolean | - | | - | +| shouldUpdatePosition | [v2 废弃] 是否在对话框重新渲染时及时更新对话框位置,一般用于对话框高度变化后依然能保证原来的对齐方式 | boolean | - | | - | +| minMargin | [v2 废弃] 对话框距离浏览器顶部和底部的最小间距,align 被设置为 'cc cc' 并且 isFullScreen 被设置为 true 时不生效 | number | 40 | | - | +| overlayProps | 透传到弹层组件的属性对象 | OverlayProps | - | | - | +| locale | 自定义国际化文案对象 | Partial\<{
    ok: string;
    cancel: string;
    }> | - | | - | +| height | 对话框的高度样式属性 | string \| number | - | | - | +| width | 弹窗宽度 | string \| number | - | | 1.25 | +| popupContainer | 自定义弹窗挂载位置 | string \| HTMLElement \| ((target?: HTMLElement) => HTMLElement) | - | | - | +| v2 | 开启 v2 版本弹窗 | false \| undefined | false | | - | +| noPadding | 去除 body 内间距 | boolean | false | | 1.26 | +| closeIcon | 定制关闭按钮 icon | React.ReactNode | - | | 1.25 | + +### Dialog V2 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------- | -------------------------------------- | -------- | -------- | +| visible | 是否显示 | boolean | false | | - | +| title | 标题 | React.ReactNode | - | | - | +| children | 内容 | React.ReactNode | - | | - | +| footer | 底部内容,设置为 false,则不进行显示 | boolean \| React.ReactNode | - | | - | +| footerAlign | 底部按钮的对齐方式 | 'left' \| 'center' \| 'right' | 'right' | | - | +| footerActions | 指定确定按钮和取消按钮是否存在以及如何排列 | Array\<'ok' \| 'cancel'> | ['ok', 'cancel'] | | - | +| cache | 隐藏时是否保留子节点,不销毁 | boolean | false | | 1.23 | +| onOk | 在点击确定按钮时触发的回调函数 | (event: React.MouseEvent) => void | - | | - | +| onCancel | 在点击取消按钮时触发的回调函数 | (event: React.MouseEvent) => void | - | | - | +| okProps | 应用于确定按钮的属性对象 | ButtonProps | - | | - | +| cancelProps | 应用于取消按钮的属性对象 | ButtonProps | - | | - | +| closeMode | [推荐] 控制对话框关闭的方式 | CloseMode[] \| CloseMode | - | | 1.21 | +| onClose | 对话框关闭时触发的回调函数 | (trigger: string, event: React.MouseEvent \| KeyboardEvent) => void | - | | - | +| afterClose | 对话框关闭后触发的回调函数,如果有动画,则在动画结束后触发 | () => void | - | | - | +| hasMask | 是否显示遮罩 | boolean | true | | - | +| animation | 显示隐藏时动画的播放方式 | Record\<'in' \| 'out', string> \| false | \{ in: 'fadeInUp', out: 'fadeOutUp' \} | | - | +| autoFocus | 对话框弹出时是否自动获得焦点 | boolean | false | | - | +| isFullScreen | [v2 废弃] 当对话框高度超过浏览器视口高度时,是否显示所有内容而不是出现滚动条以保证对话框完整显示在浏览器视口内,该属性仅在对话框垂直水平居中时生效,即 align 被设置为 'cc cc' 时 | boolean | - | | - | +| minMargin | [v2 废弃] 对话框距离浏览器顶部和底部的最小间距,align 被设置为 'cc cc' 并且 isFullScreen 被设置为 true 时不生效 | number | 40 | | - | +| overlayProps | 透传到弹层组件的属性对象 | OverlayProps | - | | - | +| locale | 自定义国际化文案对象 | Partial\<{
    ok: string;
    cancel: string;
    }> | - | | - | +| height | 对话框的高度样式属性 | string \| number | - | | - | +| popupContainer | 自定义弹窗挂载位置 | string \| HTMLElement \| ((target?: HTMLElement) => HTMLElement) | - | | - | +| v2 | 开启 v2 版本弹窗 | true | false | | - | +| closeIcon | 定制关闭按钮 icon | React.ReactNode | - | | 1.25 | +| width | 弹窗宽度 | string \| number | - | | 1.25 | +| noPadding | 去除 body 内间距 | boolean | false | | 1.26 | +| top | [v2] 弹窗上边距。默认 100,设置 centered=true 后默认 40 | number | - | | 1.25 | +| bottom | [v2] 弹窗下边距 | number | 40 | | 1.25 | +| overflowScroll | [v2] 对话框高度超过浏览器视口高度时,对话框是否展示滚动条。关闭此功后对话框会随高度撑开页面 | boolean | - | | 1.25 | +| centered | [v2] 弹窗居中对齐 | boolean | - | | 1.25 | +| dialogRender | [v2] 自定义渲染弹窗 | (modal: React.ReactNode) => React.ReactNode | - | | - | +| wrapperClassName | [v2] 最外包裹层 className | string | - | | 1.26 | +| wrapperStyle | [v2] 最外包裹层 style | React.CSSProperties | - | | 1.26 | @@ -77,41 +112,41 @@ API变化: 以下只列举 config 可以传入的常用属性,Dialog 组件的其他属性也可以传入 -| 属性 | 说明 | 类型 | 默认值 | -| :----------- | :---------------- | :-------- | :------- | -| title | 标题 | ReactNode | '' | -| content | 内容 | ReactNode | '' | -| onOk | 在点击确定按钮时触发的回调函数 | Function | () => {} | -| onCancel | 在点击取消按钮时触发的回调函数 | Function | () => {} | -| messageProps | 内嵌 Message 组件属性对象 | Object | {} | +| 属性 | 说明 | 类型 | 默认值 | +| :----------- | :----------------------------- | :-------- | :------- | +| title | 标题 | ReactNode | '' | +| content | 内容 | ReactNode | '' | +| onOk | 在点击确定按钮时触发的回调函数 | Function | () => {} | +| onCancel | 在点击取消按钮时触发的回调函数 | Function | () => {} | +| messageProps | 内嵌 Message 组件属性对象 | Object | {} | ### Dialog.show 以下只列举 config 可以传入的常用属性,Dialog 组件其他属性也可以传入 -| 属性 | 说明 | 类型 | 默认值 | -| :------- | :-------------- | :-------- | :------- | -| title | 标题 | ReactNode | '' | -| content | 内容 | ReactNode | '' | +| 属性 | 说明 | 类型 | 默认值 | +| :------- | :----------------------------- | :-------- | :------- | +| title | 标题 | ReactNode | '' | +| content | 内容 | ReactNode | '' | | onOk | 在点击确定按钮时触发的回调函数 | Function | () => {} | | onCancel | 在点击取消按钮时触发的回调函数 | Function | () => {} | ### Dialog.withContext -上面的`Dialog.alert/confirm/show`这种命令式API,虽然使用起来很方便,但是如果你的应用使用了多次`ConfigProvider`,当你通过命令式API调起的Dialog的时候,它到底会使用哪份fusion config(比如prefix、文案),是一件无法确定的事情(详见[#2005](https://github.com/alibaba-fusion/next/issues/2005))。你可以从 withContext Example 看到这个问题。 +上面的`Dialog.alert/confirm/show`这种命令式 API,虽然使用起来很方便,但是如果你的应用使用了多次`ConfigProvider`,当你通过命令式 API 调起的 Dialog 的时候,它到底会使用哪份 fusion config(比如 prefix、文案),是一件无法确定的事情(详见[#2005](https://github.com/alibaba-fusion/next/issues/2005))。你可以从 withContext Example 看到这个问题。 -为了解决这个问题,我们提供了一个新的API:`Dialog.withContext`。 -对于要使用命令式API的组件,使用`Dialog.withContext`HOC来包裹一下。然后你就可以在你的组件props.contextDialog拿到 `alert, confirm, show` 这3个命令式方法。通过这3个方法来调起的Dialog,它使用的fusion config是符合预期的。请看 withContext Example 的使用示例。 +为了解决这个问题,我们提供了一个新的 API:`Dialog.withContext`。 +对于要使用命令式 API 的组件,使用`Dialog.withContext`HOC 来包裹一下。然后你就可以在你的组件 props.contextDialog 拿到 `alert, confirm, show` 这 3 个命令式方法。通过这 3 个方法来调起的 Dialog,它使用的 fusion config 是符合预期的。请看 withContext Example 的使用示例。 ## 无障碍键盘操作指南 -| 键盘 | 说明 | -| :-------- | :--------------------------------------- | -| esc | 按下ESC键将会关闭dialog而不触发任何的动作 | -| tab | 正向聚焦到任何可以被聚焦的元素, 在Dialog显示的时候,焦点始终保持在框体内 | -| shift+tab | 反向聚焦到任何可以被聚焦的元素,在Dialog显示的时候,焦点始终保持在框体内 | +| 键盘 | 说明 | +| :-------- | :------------------------------------------------------------------------- | +| esc | 按下 ESC 键将会关闭 dialog 而不触发任何的动作 | +| tab | 正向聚焦到任何可以被聚焦的元素,在 Dialog 显示的时候,焦点始终保持在框体内 | +| shift+tab | 反向聚焦到任何可以被聚焦的元素,在 Dialog 显示的时候,焦点始终保持在框体内 | ## FAQ diff --git a/components/dialog/__docs__/theme/index.jsx b/components/dialog/__docs__/theme/index.jsx deleted file mode 100644 index d2a8bd4c45..0000000000 --- a/components/dialog/__docs__/theme/index.jsx +++ /dev/null @@ -1,196 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import '../../style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import Dialog from '../../index'; -import { ModalInner } from '../../show'; - -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; - -const i18nMaps = { - 'en-us': { - title: 'Welcome to Alibaba.com', - content: 'Start your business here by searching a popular product', - alert: 'Alert', - confirm: 'Confirm', - alertContent: 'Alert content...', - confirmContent: 'Are you sure delete this ?' - }, - - 'zh-cn': { - title: '欢迎来到 Alibaba.com', - content: '开启您的贸易生活从 Alibaba.com 开始', - alert: '警告', - confirm: '确认', - alertContent: '警告内容...', - confirmContent: '你确认删除这些内容吗?' - } -}; - -class FunctionDemo extends Component { - state = { - demoFunction: { - hasTitle: { - label: '标题', - value: 'true', - enum: [{ - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - }] - }, - hasMask: { - label: '遮罩', - value: 'false', - enum: [{ - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - }] - }, - footer: { - label: '按钮', - value: 'true', - enum: [{ - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - }] - }, - footerAlign: { - label: '按钮对齐方式', - value: 'right', - enum: [{ - label: '左侧', - value: 'left' - }, { - label: '中间', - value: 'center' - }, { - label: '右侧', - value: 'right' - }] - }, - okPosition: { - label: '确定按钮位置', - value: 'left', - enum: [{ - label: '在左', - value: 'left' - }, { - label: '在右', - value: 'right' - }] - } - } - } - onFunctionChange = demoFunction => { - this.setState({ - demoFunction - }); - } - - renderMask(hasMask, content) { - return hasMask ? ( -

    -
    - {content} -
    - ) : content; - } - - render() { - // eslint-disable-next-line - const { lang, i18n } = this.props; - const locale = (lang === 'en-us' ? enUS : zhCN).Dialog; - const hasTitle = this.state.demoFunction.hasTitle.value === 'true'; - const hasMask = this.state.demoFunction.hasMask.value === 'true'; - const footer = this.state.demoFunction.footer.value === 'true'; - const footerAlign = this.state.demoFunction.footerAlign.value; - const okIsLeft = this.state.demoFunction.okPosition.value === 'left'; - const style = hasMask ? - { position: 'absolute', top: 20, left: 20, width: 400 } : - { position: 'relative', top: 20, left: 20, width: 400 }; - const normalContent = ( - - {i18n.content} - - ); - - const alertContent = ( - - - - ); - - const confirmContent = ( - - - - ); - - return ( -
    - - - - {this.renderMask(hasMask, normalContent)} - - - - - {this.renderMask(hasMask, alertContent)} - - - {this.renderMask(hasMask, confirmContent)} - - - -
    - ); - } -} - - -const render = (lang = 'en-us') => { - const i18n = i18nMaps[lang]; - ReactDOM.render(, document.getElementById('container')); -}; - -window.renderDemo = render; -window.renderDemo('en-us'); -initDemo('dialog'); diff --git a/components/dialog/__docs__/theme/index.tsx b/components/dialog/__docs__/theme/index.tsx new file mode 100644 index 0000000000..b906a9e2d7 --- /dev/null +++ b/components/dialog/__docs__/theme/index.tsx @@ -0,0 +1,233 @@ +import React, { Component, type ReactNode } from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import '../../style'; +import { + Demo, + DemoGroup, + type DemoProps, + initDemo, + type DemoFunctionDefineForObject, +} from '../../../demo-helper'; +import Dialog from '../../index'; +import { ModalInner } from '../../show'; + +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; +import type { InnerProps } from '../../types'; + +const i18nMaps = { + 'en-us': { + title: 'Welcome to Alibaba.com', + content: 'Start your business here by searching a popular product', + alert: 'Alert', + confirm: 'Confirm', + alertContent: 'Alert content...', + confirmContent: 'Are you sure delete this ?', + }, + + 'zh-cn': { + title: '欢迎来到 Alibaba.com', + content: '开启您的贸易生活从 Alibaba.com 开始', + alert: '警告', + confirm: '确认', + alertContent: '警告内容...', + confirmContent: '你确认删除这些内容吗?', + }, +}; + +class FunctionDemo extends Component<{ lang: 'en-us' | 'zh-cn'; i18n: Record }> { + state: { + demoFunction: Record; + } = { + demoFunction: { + hasTitle: { + label: '标题', + value: 'true', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + hasMask: { + label: '遮罩', + value: 'false', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + footer: { + label: '按钮', + value: 'true', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + footerAlign: { + label: '按钮对齐方式', + value: 'right', + enum: [ + { + label: '左侧', + value: 'left', + }, + { + label: '中间', + value: 'center', + }, + { + label: '右侧', + value: 'right', + }, + ], + }, + okPosition: { + label: '确定按钮位置', + value: 'left', + enum: [ + { + label: '在左', + value: 'left', + }, + { + label: '在右', + value: 'right', + }, + ], + }, + }, + }; + onFunctionChange: DemoProps['onFunctionChange'] = demoFunction => { + this.setState({ + demoFunction, + }); + }; + + renderMask(hasMask: boolean, content: ReactNode) { + return hasMask ? ( +
    +
    + {content} +
    + ) : ( + content + ); + } + + render() { + const { lang, i18n } = this.props; + const locale = (lang === 'en-us' ? enUS : zhCN).Dialog; + const hasTitle = this.state.demoFunction.hasTitle.value === 'true'; + const hasMask = this.state.demoFunction.hasMask.value === 'true'; + const footer = this.state.demoFunction.footer.value === 'true'; + const footerAlign = this.state.demoFunction.footerAlign.value as InnerProps['footerAlign']; + const okIsLeft = this.state.demoFunction.okPosition.value === 'left'; + const style: InnerProps['style'] = hasMask + ? { position: 'absolute', top: 20, left: 20, width: 400 } + : { position: 'relative', top: 20, left: 20, width: 400 }; + const normalContent = ( + + {i18n.content} + + ); + + const alertContent = ( + + + + ); + + const confirmContent = ( + + + + ); + + return ( +
    + + + + {this.renderMask(hasMask, normalContent)} + + + + + {this.renderMask(hasMask, alertContent)} + + + {this.renderMask(hasMask, confirmContent)} + + + +
    + ); + } +} + +const render = (lang: 'en-us' | 'zh-cn' = 'en-us') => { + const i18n = i18nMaps[lang]; + ReactDOM.render(, document.getElementById('container')); +}; + +window.renderDemo = render; +window.renderDemo('en-us'); +initDemo('dialog'); diff --git a/components/dialog/__tests__/a11y-spec.js b/components/dialog/__tests__/a11y-spec.js deleted file mode 100644 index cdc3959cd7..0000000000 --- a/components/dialog/__tests__/a11y-spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Dialog from '../index'; -import '../style'; -import { test, unmount } from '../../util/__tests__/legacy/a11y/validate'; -import { roleType, isHeading, isButton } from '../../util/__tests__/legacy/a11y/checks'; - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ - -Enzyme.configure({ adapter: new Adapter() }); - -describe('Dialog A11y', () => { - describe('Basic', () => { - let wrapper; - - afterEach(() => { - if (wrapper && wrapper.unmount) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations', async () => { - wrapper = await mount(); - return test('.next-overlay-wrapper'); - }); - - it('should have accessible header', () => { - wrapper = mount(); - assert(isHeading('.next-dialog-header', wrapper)); - }); - - it('should have accessible close button', () => { - wrapper = mount(); - assert(isButton('.next-dialog-close', wrapper)); - }); - }); - - describe('Show', () => { - let hide; - - afterEach(() => { - if (hide && typeof hide === 'function') { - hide(); - hide = null; - } - }); - - it('should not have any violations', async () => { - hide = Dialog.alert({ - title: 'Title', - content: 'Content', - animation: false, - className: 'dialog-a11y-tests', - }).hide; - return test('.dialog-a11y-tests'); - }); - - it('should have role `alertdialog` for alert dialog', () => { - hide = Dialog.alert({ - title: 'Title', - content: 'Content', - animation: false, - }).hide; - assert(roleType('alertdialog', document.querySelector('.next-dialog'))); - }); - - it('should have role `alertdialog` for show dialog', () => { - hide = Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - }).hide; - assert(roleType('alertdialog', document.querySelector('.next-dialog'))); - }); - - it('should have role `alertdialog` for confirm dialog', () => { - hide = Dialog.confirm({ - title: 'Title', - content: 'Content', - animation: false, - }).hide; - assert(roleType('alertdialog', document.querySelector('.next-dialog'))); - }); - }); -}); diff --git a/components/dialog/__tests__/a11y-spec.tsx b/components/dialog/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..9f0fe6da9c --- /dev/null +++ b/components/dialog/__tests__/a11y-spec.tsx @@ -0,0 +1,80 @@ +import React from 'react'; +import Dialog from '../index'; +import '../style'; +import { test } from '../../util/__tests__/a11y/validate'; +import { roleType, isHeading, isButton } from '../../util/__tests__/a11y/checks'; + +const wrapperSelector = '.next-overlay-wrapper'; + +describe('Dialog A11y', () => { + describe('Basic', () => { + it('should not have any violations', () => { + cy.mount(); + cy.then(() => { + test(wrapperSelector); + }); + }); + + it('should have accessible header', () => { + cy.mount(); + cy.then(() => { + expect(isHeading('.next-dialog-header', wrapperSelector)).to.be.true; + }); + }); + + it('should have accessible close button', () => { + cy.mount(); + cy.then(() => { + expect(isButton('.next-dialog-close', wrapperSelector)).to.be.true; + }); + }); + }); + + describe('Show', () => { + let hide: () => void | null; + + afterEach(() => { + if (hide && typeof hide === 'function') { + hide(); + (hide as unknown as null) = null; + } + }); + + it('should not have any violations', () => { + hide = Dialog.alert({ + title: 'Title', + content: 'Content', + animation: false, + className: 'dialog-a11y-tests', + }).hide; + test('.dialog-a11y-tests'); + }); + + it('should have role `alertdialog` for alert dialog', () => { + hide = Dialog.alert({ + title: 'Title', + content: 'Content', + animation: false, + }).hide; + expect(roleType('alertdialog', '.next-dialog', wrapperSelector)).to.be.true; + }); + + it('should have role `alertdialog` for show dialog', () => { + hide = Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + }).hide; + expect(roleType('alertdialog', '.next-dialog', wrapperSelector)).to.be.true; + }); + + it('should have role `alertdialog` for confirm dialog', () => { + hide = Dialog.confirm({ + title: 'Title', + content: 'Content', + animation: false, + }).hide; + expect(roleType('alertdialog', '.next-dialog', wrapperSelector)).to.be.true; + }); + }); +}); diff --git a/components/dialog/__tests__/index-spec.js b/components/dialog/__tests__/index-spec.js deleted file mode 100644 index 6e77df5296..0000000000 --- a/components/dialog/__tests__/index-spec.js +++ /dev/null @@ -1,745 +0,0 @@ -import React, { useState } from 'react'; -import ReactDOM from 'react-dom'; -import assert from 'power-assert'; -import ReactTestUtils from 'react-dom/test-utils'; -import Enzyme, { shallow, mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import { dom } from '../../util'; -import Button from '../../button'; -import ConfigProvider from '../../config-provider'; -import Dialog from '../index'; -import Message from '../../message'; -import '../style'; -import zhCN from '../../locale/zh-cn'; -import { ModalInner as QuickInner } from '../show'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ -/* global describe it beforeEach */ - -const { hasClass, getStyle } = dom; -const render = element => { - let inc; - const container = document.createElement('div'); - document.body.appendChild(container); - ReactDOM.render(element, container, function() { - inc = this; - }); - return { - setProps: props => { - const clonedElement = React.cloneElement(element, props); - ReactDOM.render(clonedElement, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - }, - instance: () => { - return inc; - }, - find: selector => { - return container.querySelectorAll(selector); - }, - }; -}; - -class Demo extends React.Component { - state = { - visible: false, - content: '开启您的贸易生活从 Alibaba.com 开始', - }; - - onOpen = () => { - this.setState({ - visible: true, - }); - }; - - onClose = () => { - this.setState({ - visible: false, - }); - }; - - onChangeContent = () => { - this.setState({ - content: new Array(40) - .fill('') - .map((__, index) =>

    Start your business here by searching a popular product

    ), - }); - }; - - render() { - return ( -
    - - - - - {this.state.content} - -
    - ); - } -} - -describe('inner', () => { - let wrapper; - const delay = time => new Promise(resolve => setTimeout(resolve, time)); - - beforeEach(() => { - ConfigProvider.initLocales({ - 'zh-cn': zhCN, - }); - ConfigProvider.setLanguage('zh-cn'); - }); - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should show and hide', () => { - wrapper = render(); - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - assert(document.querySelector('.next-dialog')); - - ReactTestUtils.Simulate.click(btn); - const okBtn = document.querySelector('.next-btn-primary.next-dialog-btn'); - ReactTestUtils.Simulate.click(okBtn); - assert(!document.querySelector('.next-dialog')); - - ReactTestUtils.Simulate.click(btn); - const cancelBtn = document.querySelector('.next-btn-normal.next-dialog-btn'); - ReactTestUtils.Simulate.click(cancelBtn); - assert(!document.querySelector('.next-dialog')); - - ReactTestUtils.Simulate.click(btn); - const closeLink = document.querySelector('.next-dialog-close'); - ReactTestUtils.Simulate.click(closeLink); - assert(!document.querySelector('.next-dialog')); - }); - - it('should support footerAlign', () => { - wrapper = render(); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-right')); - - wrapper.setProps({ - footerAlign: 'center', - }); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-center')); - wrapper.setProps({ - footerAlign: 'left', - }); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-left')); - }); - - it('should support footerActions', () => { - wrapper = render(); - let btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertOkBtn(btns[0]); - assertCancelBtn(btns[1]); - - wrapper.setProps({ - footerActions: ['cancel', 'ok'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertCancelBtn(btns[0]); - assertOkBtn(btns[1]); - - wrapper.setProps({ - footerActions: ['ok'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertOkBtn(btns[0]); - - wrapper.setProps({ - footerActions: ['cancel'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertCancelBtn(btns[0]); - }); - - it('should support custom footer', () => { - wrapper = render(); - assert(!document.querySelector('.next-dialog-footer')); - - wrapper.setProps({ - footer: ( - - Link - - ), - }); - assert(document.querySelector('.next-dialog-footer a.custom').textContent.trim() === 'Link'); - }); - - it('should support custom footer button text', () => { - wrapper = render( - - ); - assert(document.querySelector('.custom-ok').textContent.trim() === 'my ok'); - - assert(document.querySelector('.custom-cancel').textContent.trim() === 'my cancel'); - }); - - it("should use css to position dialog if set isFullScreen to true and align to 'cc cc'", () => { - wrapper = render(); - assert(document.querySelector('.next-dialog-container')); - }); - - it('should adjust position and size if not use css to position', () => { - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - const dialogHeight = viewportHeight - 80 + 20; - - wrapper = render(); - - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - assert(getStyle(document.querySelector('.next-dialog'), 'top') === 40); - }); - - it('should update position and size when dailog content has been changed', () => { - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - - wrapper = render(); - wrapper.setProps({ - shouldUpdatePosition: true, - }); - - const contentChangeBt = document.querySelector('.contentChangeBt'); - ReactTestUtils.Simulate.click(contentChangeBt); - - const top = getStyle(document.querySelector('.next-dialog'), 'top'); - const dailogHeight = ['.next-dialog-body', '.next-dialog-footer', '.next-dialog-header'] - .map(sltor => getStyle(document.querySelector(sltor), 'height')) - .reduce((sum, height) => sum + height, 0); - - assert(dailogHeight === viewportHeight - top * 2); - }); - - it('dialog body should has max-heigth when setting smaller value of heigth', () => { - wrapper = render(); - wrapper.setProps({ - height: '200px', - shouldUpdatePosition: true, - }); - - const bodyEl = document.querySelector('.next-dialog-body'); - - assert(bodyEl.style.maxHeight === '100px'); - }); - - it('should hide close link if set closeable to false', () => { - wrapper = render(); - assert(!document.querySelector('.next-dialog-close')); - }); - - it('should support show', () => { - let called = false; - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - afterClose: () => { - called = true; - }, - }); - - assert(document.querySelector('.next-dialog')); - assert(document.querySelector('.next-dialog-header').textContent.trim() === 'Title'); - assert(document.querySelector('.next-dialog-body').textContent.trim() === 'Content'); - - hide(); - assert(!document.querySelector('.next-dialog')); - assert(called); - }); - - it('should support alert', () => { - const { hide } = Dialog.alert({ - title: 'Title', - content: 'Content', - animation: false, - }); - assert( - hasClass( - document.querySelector('.next-dialog-message.next-message.next-addon.next-large'), - 'next-message-warning' - ) - ); - assert(!document.querySelector('.next-dialog-header')); - assert(document.querySelector('.next-message-title').textContent.trim() === 'Title'); - assert(document.querySelector('.next-message-content').textContent.trim() === 'Content'); - const btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertOkBtn(btns[0]); - - hide(); - }); - - it('should support confirm', () => { - const { hide } = Dialog.confirm({ - title: 'Title', - content: 'Content', - animation: false, - }); - assert( - hasClass( - document.querySelector('.next-dialog-message.next-message.next-addon.next-large'), - 'next-message-help' - ) - ); - assert(!document.querySelector('.next-dialog-header')); - assert(document.querySelector('.next-message-title').textContent.trim() === 'Title'); - assert(document.querySelector('.next-message-content').textContent.trim() === 'Content'); - const btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertOkBtn(btns[0]); - assertCancelBtn(btns[1]); - - hide(); - }); - - it('should support height', () => { - wrapper = render(); - assert(!document.querySelector('.next-dialog').style.height); - - assert(!hasClass(document.querySelector('.next-dialog-footer'), 'next-dialog-footer-fixed-height')); - - wrapper.setProps({ - height: '500px', - }); - assert(document.querySelector('.next-dialog').style.height === '500px'); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-dialog-footer-fixed-height')); - }); - - it('should close dialog if click the ok button', () => { - Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - }); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(!document.querySelector('.next-dialog')); - }); - - it('should not close dialog if onOk return false', () => { - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - onOk: () => false, - }); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(document.querySelector('.next-dialog')); - - hide(); - }); - - it('should not close dialog immediately if onOk return promise and resolve true', done => { - Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise(resolve => { - setTimeout(resolve, 500); - }); - }, - }); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(document.querySelector('.next-dialog')); - assert(hasClass(document.querySelector('.next-btn-primary'), 'next-btn-loading')); - - setTimeout(() => { - assert(!document.querySelector('.next-dialog')); - done(); - }, 1000); - }); - - it('should not close dialog if onOk return promise and resolve false', done => { - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise(resolve => { - setTimeout(() => resolve(false), 500); - }); - }, - }); - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - - setTimeout(() => { - assert(document.querySelector('.next-dialog')); - hide(); - done(); - }, 1000); - }); - - it('should work when set ', () => { - wrapper = render( - -
    - - Start your business here by searching a popular product - -
    -
    - ); - - const overlay = document.querySelector('#dialog-popupcontainer > .next-overlay-wrapper'); - assert(overlay); - }); - - it('should not close dialog if onOk return promise and reject', done => { - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise((resolve, reject) => { - setTimeout(reject, 500); - }); - }, - }); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - - setTimeout(() => { - assert(!hasClass(document.querySelector('.next-btn-primary'), 'next-btn-loading')); - assert(document.querySelector('.next-dialog')); - hide(); - done(); - }, 1000); - }); - - it('should obey: self.locale > nearest ConfigProvider.locale > further ConfigProvider.locale', () => { - wrapper = render( - - - - - - ); - - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - - const footer = document.querySelector('.near-dialog-footer'); - const ok = footer.querySelectorAll('button')[0]; - const cancel = footer.querySelectorAll('button')[1]; - - assert(footer); - assert(ok.textContent === 'my ok'); - assert(cancel.textContent === 'near cancel'); - }); - - it("quick-calling should use root context's state if its exists", async () => { - wrapper = render( - - - , - }); - }} - > - OK - - - - ); - - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - - const footer = document.querySelector('.far-dialog-footer'); - const overlayWrapper = document.querySelector('.far-overlay-wrapper'); - const ok = footer.querySelectorAll('button')[0]; - const cancel = footer.querySelectorAll('button')[1]; - - assert(footer); - assert(overlayWrapper); - assert(ok.textContent === 'far ok'); - assert(cancel.textContent === 'my cancel'); - - cancel.click(); - await delay(800); - - assert(!document.querySelector('.far-overlay-wrapper')); - }); - - it('quick-calling should should support set prefix for dialog', () => { - const { hide } = Dialog.show({ - prefix: 'test-', - title: 'Title', - content: 'Content', - }); - - assert(hasClass(document.querySelector('.test-dialog'), 'test-closeable')); - - hide(); - }); - - it('should throw error (async)', () => { - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - onOk: async () => { - throw Error(); - }, - }); - try { - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(false); - } catch (e) { - assert(true); - } - hide(); - }); - - it('should throw error', () => { - const { hide } = Dialog.show({ - title: 'Title', - content: 'Content', - onOk: () => { - throw Error(); - }, - }); - try { - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(false); - } catch (e) { - assert(true); - } - hide(); - }); - - // https://github.com/alibaba-fusion/next/issues/2868 - it('should resize after children size changed', done => { - function MyContent() { - const [height, setHeight] = useState(100); - - return ( -
    -
    -
    - ); - } - - wrapper = render( - - - - ); - const { left, top } = document.querySelector('.next-dialog').style; - - document.querySelector('.content button').click(); - - setTimeout(() => { - const { left: newLeft, top: newTop } = document.querySelector('.next-dialog').style; - assert.notDeepEqual([left, top], [newLeft, newTop]); - done(); - }, 300); - }); -}); - -describe('Quick', () => { - let wrapper; - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should support alert message', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('type') === 'warning'); - }); - - it('should support confirm message', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('type') === 'help'); - }); - - it('should support message title', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('title') === 'quick confirm modal inner'); - }); - - it('should support default prefix', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('className') === 'next-dialog-message'); - }); - - it('should support messageProps', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('testProp') === 'test'); - }); - - it('should support custom prefix', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.prop('className') === 'test-dialog-message'); - }); - - it('should pass content as child', () => { - wrapper = shallow( - Modal Content} - /> - ); - - const message = wrapper.find(Message); - assert(message.children().type() === 'span'); - }); -}); - -function assertOkBtn(btn) { - assert(hasClass(btn, 'next-btn-primary')); - assert(btn.textContent.trim() === '确定'); -} - -function assertCancelBtn(btn) { - assert(hasClass(btn, 'next-btn-normal')); - assert(btn.textContent.trim() === '取消'); -} diff --git a/components/dialog/__tests__/index-spec.tsx b/components/dialog/__tests__/index-spec.tsx new file mode 100644 index 0000000000..f711783e08 --- /dev/null +++ b/components/dialog/__tests__/index-spec.tsx @@ -0,0 +1,687 @@ +import React, { type ReactElement, cloneElement, useState } from 'react'; +import { type MountReturn } from 'cypress/react'; +import { dom } from '../../util'; +import Button from '../../button'; +import ConfigProvider from '../../config-provider'; +import Dialog, { type DialogProps } from '../index'; +import '../style'; +import zhCN from '../../locale/zh-cn'; +import { ModalInner as QuickInner } from '../show'; + +const { getStyle } = dom; + +function shouldOkBtn(btn: Cypress.Chainable>) { + btn.should('have.class', 'next-btn-primary'); + btn.should('have.text', '确定'); +} + +function shouldCancelBtn(btn: Cypress.Chainable>) { + btn.should('have.class', 'next-btn-normal'); + btn.should('have.text', '取消'); +} + +const COUNT = 5; + +class Demo extends React.Component { + state = { + visible: false, + content: '开启您的贸易生活从 Alibaba.com 开始', + }; + + onOpen = () => { + this.setState({ + visible: true, + }); + }; + + onClose = () => { + this.setState({ + visible: false, + }); + }; + + onChangeContent = () => { + this.setState({ + content: new Array(COUNT) + .fill('') + .map((__, index) => ( +

    Start your business here by searching a popular product

    + )), + }); + }; + + render() { + return ( +
    + + + + + {this.state.content} + +
    + ); + } +} + +describe('inner', () => { + beforeEach(() => { + ConfigProvider.initLocales({ + 'zh-cn': zhCN, + }); + ConfigProvider.setLanguage('zh-cn'); + }); + + it('should show and hide', () => { + cy.mount(); + cy.get('button').click(); + cy.get('.next-dialog').should('exist'); + + cy.get('.next-btn-primary.next-dialog-btn').click(); + cy.get('.next-dialog').should('not.exist'); + + cy.get('button').click(); + cy.get('.next-btn-normal.next-dialog-btn').click(); + cy.get('.next-dialog').should('not.exist'); + + cy.get('button').click(); + cy.get('.next-dialog-close').click(); + cy.get('.next-dialog').should('not.exist'); + }); + + it('should support footerAlign', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-footer').should('have.class', 'next-align-right'); + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerAlign: 'center' })); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-align-center'); + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerAlign: 'left' })); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-align-left'); + }); + + it('should support footerActions', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(1)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['cancel', 'ok'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(0)); + shouldOkBtn(cy.get('.next-dialog-btn').eq(1)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['ok'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['cancel'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(0)); + }); + + it('should support custom footer', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-footer').should('not.exist'); + cy.get('@Demo').then(({ component, rerender }) => { + rerender( + cloneElement(component as ReactElement, { + footer:
    Link, + }) + ); + }); + cy.get('.next-dialog-footer a.custom').should('have.text', 'Link'); + }); + + it('should support custom footer button text', () => { + cy.mount( + + ); + cy.get('.custom-ok').should('have.text', 'my ok'); + cy.get('.custom-cancel').should('have.text', 'my cancel'); + }); + + it("should use css to position dialog if set isFullScreen to true and align to 'cc cc'", () => { + cy.mount(); + cy.get('.next-dialog-container').should('exist'); + }); + + it('should adjust position and size if not use css to position', () => { + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + const dialogHeight = viewportHeight - 80 + 20; + + cy.mount(); + cy.get('button').click(); + cy.get('.next-dialog').should('have.css', 'top', '40px'); + }); + + it('should update position and size when dailog content has been changed', () => { + cy.mount(); + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + let contentHeight = 28; + // 42 为 body padding + dialog border + const extraHeight = 40 + 2; + const heightAndTopShouldBeOk = ($dialog: JQuery) => { + const headerHeight = getStyle( + document.querySelector('.next-dialog-header')!, + 'height' + ) as number; + const footerHeight = getStyle( + document.querySelector('.next-dialog-footer')!, + 'height' + ) as number; + const expectDialogHeight = headerHeight + footerHeight + contentHeight + extraHeight; + cy.wrap($dialog).should('have.css', 'height', `${expectDialogHeight}px`); + cy.wrap($dialog).should( + 'have.css', + 'top', + `${(viewportHeight - expectDialogHeight) / 2}px` + ); + }; + cy.get('.next-dialog').then($dialog => { + heightAndTopShouldBeOk($dialog); + }); + cy.get('.contentChangeBt').click(); + cy.get('.next-dialog').then($dialog => { + contentHeight = 28 + COUNT * (18 + 12) + 12; + heightAndTopShouldBeOk($dialog); + }); + }); + + it('dialog body should has max-height when setting smaller value of height', () => { + cy.mount(); + + cy.get('.next-dialog-body').should('have.css', 'max-height', '100px'); + }); + + it('should hide close link if set closeable to false', () => { + cy.mount(); + cy.get('.next-dialog-close').should('not.exist'); + }); + + it('should support show', () => { + const handleClose = cy.spy().as('handleClose'); + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + afterClose: handleClose, + }); + + cy.get('.next-dialog').should('exist'); + cy.get('.next-dialog-header').should('have.text', 'Title'); + cy.get('.next-dialog-body').should('have.text', 'Content'); + cy.then(() => { + hide(); + }); + + cy.get('.next-dialog').should('not.exist'); + cy.get('@handleClose').should('be.called'); + }); + + it('should support alert', () => { + const { hide } = Dialog.alert({ + title: 'Title', + content: 'Content', + animation: false, + }); + cy.get('.next-dialog-message.next-message.next-addon.next-large').should( + 'have.class', + 'next-message-warning' + ); + cy.get('.next-dialog-header').should('not.exist'); + cy.get('.next-message-title').should('have.text', 'Title'); + cy.get('.next-message-content').should('have.text', 'Content'); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + cy.then(() => { + hide(); + }); + }); + + it('should support confirm', () => { + const { hide } = Dialog.confirm({ + title: 'Title', + content: 'Content', + animation: false, + }); + cy.get('.next-dialog-message.next-message.next-addon.next-large').should( + 'have.class', + 'next-message-help' + ); + cy.get('.next-dialog-header').should('not.exist'); + cy.get('.next-message-title').should('have.text', 'Title'); + cy.get('.next-message-content').should('have.text', 'Content'); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(1)); + cy.then(() => { + hide(); + }); + }); + + it('should support height', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog').then($el => { + cy.wrap($el.prop('style').height).should('be.empty'); + }); + cy.get('.next-dialog-footer').should('not.have.class', 'next-dialog-footer-fixed-height'); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender( + cloneElement(component as ReactElement, { + height: '500px', + }) + ); + }); + cy.get('.next-dialog').then($el => { + cy.wrap($el.prop('style').height).should('be.equal', '500px'); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-dialog-footer-fixed-height'); + }); + + it('should close dialog if click the ok button', () => { + Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + }); + + cy.get('.next-btn-primary').click(); + cy.get('.next-dialog').should('not.exist'); + }); + + it('should not close dialog if onOk return false', () => { + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + onOk: () => false, + }); + + cy.get('.next-btn-primary').click(); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should not close dialog immediately if onOk return promise and resolve true', () => { + Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise(resolve => { + setTimeout(resolve, 500); + }); + }, + }); + + cy.get('.next-btn-primary').click(); + cy.get('.next-dialog').should('exist'); + cy.get('.next-btn-primary').should('have.class', 'next-btn-loading'); + cy.get('.next-dialog').should('not.exist'); + }); + + it('should not close dialog if onOk return promise and resolve false', () => { + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise(resolve => { + setTimeout(() => resolve(false), 500); + }); + }, + }); + cy.get('.next-btn-primary').click(); + // 这里的 wait 是有必要的 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should work when set ', () => { + cy.mount( + +
    + + Start your business here by searching a popular product + +
    +
    + ); + cy.get('#dialog-popupcontainer > .next-overlay-wrapper').should('exist'); + }); + + it('should not close dialog if onOk return promise and reject', () => { + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise((resolve, reject) => { + setTimeout(() => { + reject('test'); + }, 500); + }); + }, + }); + + cy.on('uncaught:exception', () => { + return false; + }); + + cy.get('.next-btn-primary').click(); + // 这里的 wait 是有必要的 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-btn-primary').should('not.have.class', 'next-btn-loading'); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should obey: self.locale > nearest ConfigProvider.locale > further ConfigProvider.locale', () => { + cy.mount( + + + + + + ); + + cy.get('button').click(); + + cy.get('.near-dialog-footer').as('footer').should('exist'); + cy.get('@footer').find('button').eq(0).should('have.text', 'my ok'); + cy.get('@footer').find('button').eq(1).should('have.text', 'near cancel'); + }); + + it("quick-calling should use root context's state if its exists", () => { + cy.mount( + + + , + }); + }} + > + OK + + + + ); + + cy.get('button').click(); + cy.get('.far-dialog-footer').as('footer').should('exist'); + cy.get('@footer').find('button').eq(0).should('have.text', 'far ok'); + cy.get('@footer').find('button').eq(1).as('cancel').should('have.text', 'my cancel'); + + cy.get('@cancel').click(); + cy.get('.far-overlay-wrapper').should('not.exist'); + }); + + it('quick-calling should should support set prefix for dialog', () => { + const { hide } = Dialog.show({ + prefix: 'test-', + title: 'Title', + content: 'Content', + }); + + cy.get('.test-dialog').should('have.class', 'test-closeable'); + cy.wrap(() => { + hide(); + }); + }); + + it('should throw error (async)', () => { + const handleError = cy.spy().as('handleError'); + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + onOk: async () => { + throw Error('for test'); + }, + }); + cy.on('uncaught:exception', err => { + expect(err.message).to.contain('for test'); + handleError(); + hide(); + return false; + }); + cy.get('.next-btn-primary').eq(0).click(); + cy.get('@handleError').should('be.called'); + }); + + it('should throw error', () => { + const handleError = cy.spy().as('handleError'); + const { hide } = Dialog.show({ + title: 'Title', + content: 'Content', + onOk: () => { + throw Error('for test'); + }, + }); + cy.on('uncaught:exception', err => { + expect(err.message).to.contain('for test'); + handleError(); + hide(); + return false; + }); + cy.get('.next-btn-primary').eq(0).click(); + cy.get('@handleError').should('be.called'); + }); + + // https://github.com/alibaba-fusion/next/issues/2868 + it('should resize after children size changed', () => { + function MyContent() { + const [height, setHeight] = useState(100); + + return ( +
    +
    +
    + ); + } + let left: string, top: string; + cy.mount( + + + + ); + // 等待 dialog 调整完成 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-dialog').then($el => { + left = $el.css('left'); + top = $el.css('top'); + }); + + cy.get('.content button').click(); + cy.then(() => { + cy.get('.next-dialog').should('not.have.css', 'top', top); + cy.get('.next-dialog').should('have.css', 'left', left); + }); + }); +}); + +describe('Quick', () => { + it('should support alert message', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-message-warning').should('exist'); + }); + + it('should support confirm message', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-message-help').should('exist'); + }); + + it('should support message title', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-message-title').should('have.text', 'quick confirm modal inner'); + }); + + it('should support default prefix', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-dialog-message').should('exist'); + }); + + it('should support messageProps', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-icon-close').should('exist'); + }); + + it('should support custom prefix', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.test-dialog-message').should('exist'); + }); + + it('should pass content as child', () => { + cy.mount( + Modal Content} + /> + ); + + cy.get('.next-message-content').should('have.text', 'Modal Content'); + }); +}); diff --git a/components/dialog/__tests__/index-v2-spec.js b/components/dialog/__tests__/index-v2-spec.js deleted file mode 100644 index 4ba2218776..0000000000 --- a/components/dialog/__tests__/index-v2-spec.js +++ /dev/null @@ -1,664 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import assert from 'power-assert'; -import ReactTestUtils from 'react-dom/test-utils'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import { dom } from '../../util'; -import Button from '../../button'; -import ConfigProvider from '../../config-provider'; -import Dialog from '../index'; -import '../style'; -import zhCN from '../../locale/zh-cn'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ -/* global describe it beforeEach */ - -const { hasClass, getStyle } = dom; -const render = element => { - let inc; - const container = document.createElement('div'); - document.body.appendChild(container); - ReactDOM.render(element, container, function() { - inc = this; - }); - return { - setProps: props => { - const clonedElement = React.cloneElement(element, props); - ReactDOM.render(clonedElement, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - }, - instance: () => { - return inc; - }, - find: selector => { - return container.querySelectorAll(selector); - }, - }; -}; - -class Demo2 extends React.Component { - state = { - visible: false, - content: '开启您的贸易生活从 Alibaba.com 开始', - }; - - onOpen = () => { - this.setState({ - visible: true, - }); - }; - - onClose = () => { - this.setState({ - visible: false, - }); - }; - - render() { - return ( -
    - - - - {this.state.content} - -
    - ); - } -} - -describe('v2', () => { - let wrapper; - const delay = time => new Promise(resolve => setTimeout(resolve, time)); - - beforeEach(() => { - ConfigProvider.initLocales({ - 'zh-cn': zhCN, - }); - ConfigProvider.setLanguage('zh-cn'); - }); - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - document.body.style = ''; - }); - - it('should show and hide with no cache', async () => { - wrapper = render(); - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - await delay(40); - assert(document.querySelector('.next-dialog')); - - const okBtn = document.querySelector('.next-btn-primary.next-dialog-btn'); - ReactTestUtils.Simulate.click(okBtn); - await delay(40); - // no cache should unmount - assert(!document.querySelector('.next-dialog')); - - ReactTestUtils.Simulate.click(btn); - await delay(40); - const cancelBtn = document.querySelector('.next-btn-normal.next-dialog-btn'); - ReactTestUtils.Simulate.click(cancelBtn); - await delay(40); - assert(!document.querySelector('.next-dialog')); - - ReactTestUtils.Simulate.click(btn); - await delay(40); - const closeLink = document.querySelector('.next-dialog-close'); - ReactTestUtils.Simulate.click(closeLink); - await delay(40); - assert(!document.querySelector('.next-dialog')); - }); - - it('should support footerAlign', () => { - wrapper = render(); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-right')); - - wrapper.setProps({ - footerAlign: 'center', - }); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-center')); - wrapper.setProps({ - footerAlign: 'left', - }); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-align-left')); - }); - - it('should support footerActions', () => { - wrapper = render(); - let btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertOkBtn(btns[0]); - assertCancelBtn(btns[1]); - - wrapper.setProps({ - footerActions: ['cancel', 'ok'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertCancelBtn(btns[0]); - assertOkBtn(btns[1]); - - wrapper.setProps({ - footerActions: ['ok'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertOkBtn(btns[0]); - - wrapper.setProps({ - footerActions: ['cancel'], - }); - btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertCancelBtn(btns[0]); - }); - - it('should support custom footer', () => { - wrapper = render(); - assert(!document.querySelector('.next-dialog-footer')); - - wrapper.setProps({ - footer: ( - - Link - - ), - }); - assert(document.querySelector('.next-dialog-footer a.custom').textContent.trim() === 'Link'); - }); - - it('should support typeof closeMode === string', () => { - wrapper = render(); - assert(document.querySelector('.next-dialog')); - }); - - it('should support closeIcon', () => { - wrapper = render(x} />); - assert(document.querySelector('.closeicon').textContent.trim() === 'x'); - }); - - it('should support custom footer button text', () => { - wrapper = render( - - ); - assert(document.querySelector('.custom-ok').textContent.trim() === 'my ok'); - - assert(document.querySelector('.custom-cancel').textContent.trim() === 'my cancel'); - }); - - it('should support show', async () => { - let called = false; - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - afterClose: () => { - called = true; - }, - }); - - await delay(20); - assert(document.querySelector('.next-dialog')); - assert(document.querySelector('.next-dialog-header').textContent.trim() === 'Title'); - assert(document.querySelector('.next-dialog-body').textContent.trim() === 'Content'); - - hide(); - await delay(20); - assert(!document.querySelector('.next-dialog')); - assert(called); - }); - - it('should support alert', () => { - const { hide } = Dialog.alert({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - }); - assert( - hasClass( - document.querySelector('.next-dialog-message.next-message.next-addon.next-large'), - 'next-message-warning' - ) - ); - assert(!document.querySelector('.next-dialog-header')); - assert(document.querySelector('.next-message-title').textContent.trim() === 'Title'); - assert(document.querySelector('.next-message-content').textContent.trim() === 'Content'); - const btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 1); - assertOkBtn(btns[0]); - - hide(); - }); - - it('should support confirm', async () => { - const { hide } = Dialog.confirm({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - }); - await delay(20); - assert( - hasClass( - document.querySelector('.next-dialog-message.next-message.next-addon.next-large'), - 'next-message-help' - ) - ); - assert(!document.querySelector('.next-dialog-header')); - assert(document.querySelector('.next-message-title').textContent.trim() === 'Title'); - assert(document.querySelector('.next-message-content').textContent.trim() === 'Content'); - const btns = document.querySelectorAll('.next-dialog-btn'); - assert(btns.length === 2); - assertOkBtn(btns[0]); - assertCancelBtn(btns[1]); - - hide(); - }); - - it('should support height', async () => { - wrapper = render(); - await delay(20); - assert(!document.querySelector('.next-dialog').style.height); - - assert(!hasClass(document.querySelector('.next-dialog-footer'), 'next-dialog-footer-fixed-height')); - - wrapper.setProps({ - height: '500px', - }); - assert(document.querySelector('.next-dialog').style.height === '500px'); - assert(hasClass(document.querySelector('.next-dialog-footer'), 'next-dialog-footer-fixed-height')); - }); - - it('should support style.width compcat with v1', async () => { - wrapper = render(); - await delay(20); - assert(document.querySelector('.next-dialog').style.width === '345px'); - }); - - it('should close dialog if click the ok button', async () => { - Dialog.show({ - v2: true, - animation: false, - title: 'Title', - content: 'Content', - animation: false, - }); - - await delay(20); - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - await delay(20); - assert(!document.querySelector('.next-dialog')); - }); - - it('should not close dialog if onOk return false', async () => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - onOk: () => false, - }); - - await delay(20); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - await delay(20); - - assert(document.querySelector('.next-dialog')); - - hide(); - }); - - it('should not close dialog immediately if onOk return promise and resolve true', async () => { - Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise(resolve => { - setTimeout(resolve, 100); - }); - }, - }); - - await delay(20); - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - await delay(20); - - assert(document.querySelector('.next-dialog')); - assert(hasClass(document.querySelector('.next-btn-primary'), 'next-btn-loading')); - - await delay(100); - assert(!document.querySelector('.next-dialog')); - }); - - it('should not close dialog if onOk return promise and resolve false', done => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise(resolve => { - setTimeout(() => resolve(false), 100); - }); - }, - }); - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - - setTimeout(() => { - assert(document.querySelector('.next-dialog')); - hide(); - done(); - }, 200); - }); - - it('should work when set ', async () => { - wrapper = render( - -
    - - Start your business here by searching a popular product - -
    -
    - ); - - await delay(20); - const overlay = document.querySelector('#dialog-popupcontainer > .next-overlay-wrapper'); - assert(overlay); - }); - - it('should not close dialog if onOk return promise and reject', done => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - animation: false, - onOk: () => { - return new Promise((resolve, reject) => { - setTimeout(reject, 100); - }); - }, - }); - - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - - setTimeout(() => { - assert(!hasClass(document.querySelector('.next-btn-primary'), 'next-btn-loading')); - assert(document.querySelector('.next-dialog')); - hide(); - done(); - }, 200); - }); - - it('should obey: self.locale > nearest ConfigProvider.locale > further ConfigProvider.locale', async () => { - wrapper = render( - - - - - - ); - - await delay(20); - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - - await delay(20); - const footer = document.querySelector('.near-dialog-footer'); - const ok = footer.querySelectorAll('button')[0]; - const cancel = footer.querySelectorAll('button')[1]; - - assert(footer); - assert(ok.textContent === 'my ok'); - assert(cancel.textContent === 'near cancel'); - }); - - it("quick-calling should use root context's state if its exists", async () => { - wrapper = render( - - - , - }); - }} - > - OK - - - - ); - - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - - const footer = document.querySelector('.far-dialog-footer'); - const overlayWrapper = document.querySelector('.far-overlay-wrapper'); - const ok = footer.querySelectorAll('button')[0]; - const cancel = footer.querySelectorAll('button')[1]; - - assert(footer); - assert(overlayWrapper); - assert(ok.textContent === 'far ok'); - assert(cancel.textContent === 'my cancel'); - - cancel.click(); - await delay(800); - - assert(!document.querySelector('.far-overlay-wrapper')); - }); - - it('quick-calling should should support set prefix for dialog', () => { - const { hide } = Dialog.show({ - v2: true, - prefix: 'test-', - title: 'Title', - content: 'Content', - }); - - assert(hasClass(document.querySelector('.test-dialog'), 'test-closeable')); - - hide(); - }); - - it('should throw error (async)', () => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - onOk: async () => { - throw Error(); - }, - }); - try { - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(false); - } catch (e) { - assert(true); - } - hide(); - }); - - it('should throw error', () => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - onOk: () => { - throw Error(); - }, - }); - try { - ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - assert(false); - } catch (e) { - assert(true); - } - hide(); - }); - - it('should support okProps={loading:true} ', () => { - const { hide } = Dialog.show({ - v2: true, - title: 'Title', - content: 'Content', - okProps: { - loading: true, - }, - }); - - assert(document.querySelector('.next-btn-loading')); - hide(); - }); - it('should support hasMask={false}', async () => { - const overlays = document.querySelectorAll('.next-overlay-wrapper'); - overlays.forEach(o => { - try { - o.parentElement.removeChild(o); - } catch (e) {} - }); - - const { hide } = Dialog.show({ - v2: true, - hasMask: false, - title: 'Title', - content: 'Content', - }); - - await delay(40); - assert(!document.querySelector('.next-overlay-backdrop')); - hide(); - - wrapper = render(); - const btn = document.querySelector('button'); - ReactTestUtils.Simulate.click(btn); - await delay(40); - assert(!document.querySelector('.next-overlay-backdrop')); - }); - // 测试环境隔离问题一直搞不定,先注释 - // it('should rollback document.body.style in order', async () => { - // document.body.setAttribute('style', ''); - // const config = { - // v2: true, - // animation: false, - // title: 'First', - // content: 'content content content...', - // onOk: () => { - // Dialog.success({ - // v2: true, - // animation: false, - // title: 'Second', - // content: 'content content content...' - // }); - // }, - // }; - - // Dialog.success(config); - - // await delay(40); - // assert(document.body.getAttribute('style').match('overflow: hidden')); - - // assert(document.querySelectorAll('.next-btn-primary').length == 1); - // ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - // await delay(40); - // assert(document.body.getAttribute('style').match('overflow: hidden')); - // assert(document.querySelectorAll('.next-btn-primary').length === 1); - - // ReactTestUtils.Simulate.click(document.querySelector('.next-btn-primary')); - // await delay(40); - // assert(document.body.getAttribute('style') === ''); - // }); -}); - -function assertOkBtn(btn) { - assert(hasClass(btn, 'next-btn-primary')); - assert(btn.textContent.trim() === '确定'); -} - -function assertCancelBtn(btn) { - assert(hasClass(btn, 'next-btn-normal')); - assert(btn.textContent.trim() === '取消'); -} diff --git a/components/dialog/__tests__/index-v2-spec.tsx b/components/dialog/__tests__/index-v2-spec.tsx new file mode 100644 index 0000000000..8dd078701a --- /dev/null +++ b/components/dialog/__tests__/index-v2-spec.tsx @@ -0,0 +1,584 @@ +import React, { type ReactElement, cloneElement } from 'react'; +import { type MountReturn } from 'cypress/react'; +import Button from '../../button'; +import ConfigProvider from '../../config-provider'; +import Dialog, { type ShowConfig, type DialogProps } from '../index'; +import '../style'; +import zhCN from '../../locale/zh-cn'; + +function shouldOkBtn(btn: Cypress.Chainable>) { + btn.should('have.class', 'next-btn-primary'); + btn.should('have.text', '确定'); +} + +function shouldCancelBtn(btn: Cypress.Chainable>) { + btn.should('have.class', 'next-btn-normal'); + btn.should('have.text', '取消'); +} + +class Demo2 extends React.Component { + state = { + visible: false, + content: '开启您的贸易生活从 Alibaba.com 开始', + }; + + onOpen = () => { + this.setState({ + visible: true, + }); + }; + + onClose = () => { + this.setState({ + visible: false, + }); + }; + + render() { + return ( +
    + + + + {this.state.content} + +
    + ); + } +} + +describe('v2', () => { + beforeEach(() => { + ConfigProvider.initLocales({ + 'zh-cn': zhCN, + }); + ConfigProvider.setLanguage('zh-cn'); + }); + + it('should show and hide with no cache', () => { + cy.mount(); + cy.get('button').eq(0).as('triggerButton').click(); + cy.get('.next-dialog').should('exist'); + + cy.get('.next-btn-primary.next-dialog-btn').click(); + cy.get('.next-dialog').should('not.exist'); + + cy.get('@triggerButton').click(); + cy.get('.next-btn-normal.next-dialog-btn').click(); + cy.get('.next-dialog').should('not.exist'); + + cy.get('@triggerButton').click(); + cy.get('.next-dialog-close').click(); + cy.get('.next-dialog').should('not.exist'); + }); + + it('should support footerAlign', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-footer').should('have.class', 'next-align-right'); + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerAlign: 'center' })); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-align-center'); + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerAlign: 'left' })); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-align-left'); + }); + + it('should support footerActions', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(1)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['cancel', 'ok'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(0)); + shouldOkBtn(cy.get('.next-dialog-btn').eq(1)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['ok'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender(cloneElement(component as ReactElement, { footerActions: ['cancel'] })); + }); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(0)); + }); + + it('should support custom footer', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog-footer').should('not.exist'); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender( + cloneElement(component as ReactElement, { + footer: Link, + }) + ); + }); + + cy.get('.next-dialog-footer a.custom').should('have.text', 'Link'); + }); + + it('should support typeof closeMode === string', () => { + cy.mount(); + cy.get('.next-dialog').should('exist'); + }); + + it('should support closeIcon', () => { + cy.mount(x} />); + cy.get('.closeicon').should('have.text', 'x'); + }); + + it('should support custom footer button text', () => { + cy.mount( + + ); + cy.get('.custom-ok').should('have.text', 'my ok'); + cy.get('.custom-cancel').should('have.text', 'my cancel'); + }); + + it('should support show', () => { + const handleClose = cy.spy().as('handleClose'); + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + afterClose: handleClose, + }); + + cy.get('.next-dialog').should('exist'); + cy.get('.next-dialog-header').should('have.text', 'Title'); + cy.get('.next-dialog-body').should('have.text', 'Content'); + + cy.then(() => { + hide(); + }); + + cy.get('.next-dialog').should('not.exist'); + + cy.get('@handleClose').should('be.called'); + }); + + it('should support alert', () => { + const { hide } = Dialog.alert({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + }); + cy.get('.next-dialog-message.next-message.next-addon.next-large').should( + 'have.class', + 'next-message-warning' + ); + cy.get('.next-dialog-header').should('not.exist'); + cy.get('.next-message-title').should('have.text', 'Title'); + cy.get('.next-message-content').should('have.text', 'Content'); + cy.get('.next-dialog-btn').should('have.length', 1); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + cy.then(() => { + hide(); + }); + }); + + it('should support confirm', () => { + const { hide } = Dialog.confirm({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + }); + cy.get('.next-dialog-message.next-message.next-addon.next-large').should( + 'have.class', + 'next-message-help' + ); + cy.get('.next-dialog-header').should('not.exist'); + cy.get('.next-message-title').should('have.text', 'Title'); + cy.get('.next-message-content').should('have.text', 'Content'); + cy.get('.next-dialog-btn').should('have.length', 2); + shouldOkBtn(cy.get('.next-dialog-btn').eq(0)); + shouldCancelBtn(cy.get('.next-dialog-btn').eq(1)); + cy.then(() => { + hide(); + }); + hide(); + }); + + it('should support height', () => { + cy.mount().as('Demo'); + cy.get('.next-dialog').then($el => { + cy.wrap($el.prop('style').height).should('be.empty'); + }); + cy.get('.next-dialog-footer').should('not.have.class', 'next-dialog-footer-fixed-height'); + + cy.get('@Demo').then(({ component, rerender }) => { + rerender( + cloneElement(component as ReactElement, { + height: '500px', + }) + ); + }); + cy.get('.next-dialog').then($el => { + cy.wrap($el.prop('style').height).should('be.equal', '500px'); + }); + cy.get('.next-dialog-footer').should('have.class', 'next-dialog-footer-fixed-height'); + }); + + it('should support style.width compcat with v1', () => { + cy.mount(); + cy.get('.next-dialog').should('have.css', 'width', '345px'); + }); + + it('should close dialog if click the ok button', () => { + const { hide } = Dialog.show({ + v2: true, + animation: false, + title: 'Title', + content: 'Content', + }); + + cy.get('.next-btn-primary').click(); + + cy.get('.next-dialog').should('not.exist'); + cy.then(() => { + hide(); + }); + }); + + it('should not close dialog if onOk return false', () => { + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + onOk: () => false, + }); + + cy.get('.next-btn-primary').click(); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should not close dialog immediately if onOk return promise and resolve true', () => { + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise(resolve => { + setTimeout(resolve, 100); + }); + }, + }); + + cy.get('.next-btn-primary').click(); + cy.get('.next-dialog').should('exist'); + cy.get('.next-btn-primary').should('have.class', 'next-btn-loading'); + cy.get('.next-dialog').should('not.exist'); + cy.then(() => { + hide(); + }); + }); + + it('should not close dialog if onOk return promise and resolve false', () => { + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise(resolve => { + setTimeout(() => resolve(false), 100); + }); + }, + }); + cy.get('.next-btn-primary').eq(0).click(); + // 这里的 wait 是有必要的 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(500); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should work when set ', () => { + cy.mount( + +
    + + Start your business here by searching a popular product + +
    +
    + ); + + cy.get('#dialog-popupcontainer > .next-overlay-wrapper').should('exist'); + }); + + it('should not close dialog if onOk return promise and reject', () => { + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + animation: false, + onOk: () => { + return new Promise((resolve, reject) => { + setTimeout(reject, 100); + }); + }, + }); + + cy.on('uncaught:exception', () => { + return false; + }); + + cy.get('.next-btn-primary').click(); + // 这里的 wait 是有必要的 + // eslint-disable-next-line cypress/no-unnecessary-waiting + cy.wait(1000); + cy.get('.next-btn-primary').should('not.have.class', 'next-btn-loading'); + cy.get('.next-dialog').should('exist'); + cy.then(() => { + hide(); + }); + }); + + it('should obey: self.locale > nearest ConfigProvider.locale > further ConfigProvider.locale', () => { + cy.mount( + + + + + + ); + + cy.get('button').click(); + + cy.get('.near-dialog-footer').as('footer').should('exist'); + cy.get('@footer').find('button').eq(0).should('have.text', 'my ok'); + cy.get('@footer').find('button').eq(1).should('have.text', 'near cancel'); + }); + + it("quick-calling should use root context's state if its exists", () => { + cy.mount( + + + , + }); + }} + > + OK + + + + ); + + cy.get('button').click(); + cy.get('.far-dialog-footer').as('footer').should('exist'); + cy.get('@footer').find('button').eq(0).should('have.text', 'far ok'); + cy.get('@footer').find('button').eq(1).as('cancel').should('have.text', 'my cancel'); + + cy.get('@cancel').click(); + cy.get('.far-overlay-wrapper').should('not.exist'); + }); + + it('quick-calling should should support set prefix for dialog', () => { + const { hide } = Dialog.show({ + v2: true, + prefix: 'test-', + title: 'Title', + content: 'Content', + }); + + cy.get('.test-dialog').should('have.class', 'test-closeable'); + cy.then(() => { + hide(); + }); + }); + + it('should throw error (async)', () => { + const handleError = cy.spy().as('handleError'); + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + onOk: async () => { + throw Error('for test'); + }, + }); + cy.on('uncaught:exception', err => { + expect(err.message).to.contain('for test'); + handleError(); + hide(); + return false; + }); + cy.get('.next-btn-primary').eq(0).click(); + cy.get('@handleError').should('be.called'); + }); + + it('should throw error', () => { + const handleError = cy.spy().as('handleError'); + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + onOk: () => { + throw Error('for test'); + }, + }); + cy.on('uncaught:exception', err => { + expect(err.message).to.contain('for test'); + handleError(); + hide(); + return false; + }); + cy.get('.next-btn-primary').eq(0).click(); + cy.get('@handleError').should('be.called'); + }); + + it('should support okProps={loading:true}', () => { + const { hide } = Dialog.show({ + v2: true, + title: 'Title', + content: 'Content', + okProps: { + loading: true, + }, + }); + + cy.get('.next-btn-loading').should('exist'); + cy.then(() => { + hide(); + }); + }); + it('should support hasMask={false}', () => { + const { hide } = Dialog.show({ + v2: true, + hasMask: false, + title: 'Title', + content: 'Content', + }); + + cy.get('.next-overlay-backdrop').should('not.exist'); + cy.then(() => { + hide(); + }); + + cy.mount(); + cy.get('button').eq(0).click(); + cy.get('.next-overlay-backdrop').should('not.exist'); + }); + + it('should rollback document.body.style in order', () => { + document.body.setAttribute('style', ''); + const config: ShowConfig = { + v2: true, + animation: false, + title: 'First', + content: 'content content content...', + onOk: () => { + Dialog.success({ + v2: true, + animation: false, + title: 'Second', + content: 'content content content...', + }); + }, + }; + + cy.get('body').should('have.css', 'overflow', 'visible'); + + cy.then(() => { + Dialog.success(config); + }); + + cy.get('body').should('have.css', 'overflow', 'hidden'); + + cy.get('.next-btn-primary').eq(0).click(); + + cy.get('body').should('have.css', 'overflow', 'hidden'); + + cy.get('.next-btn-primary').eq(0).click(); + + cy.get('body').should('have.css', 'overflow', 'visible'); + }); +}); diff --git a/components/dialog/__tests__/ssr-node.tsx b/components/dialog/__tests__/ssr-node.tsx new file mode 100644 index 0000000000..e3caf90f97 --- /dev/null +++ b/components/dialog/__tests__/ssr-node.tsx @@ -0,0 +1,15 @@ +import { renderToStaticMarkup } from 'react-dom/server'; +import React from 'react'; +import { expect } from 'chai'; +import Dialog from '../index'; + +describe('ssr-node', () => { + it('should be ok', () => { + const html = renderToStaticMarkup(); + expect(html).to.equal(''); + }); + it('[v2] should be ok', () => { + const html = renderToStaticMarkup(); + expect(html).to.equal(''); + }); +}); diff --git a/components/dialog/dialog-v2.jsx b/components/dialog/dialog-v2.jsx deleted file mode 100644 index e5481bde31..0000000000 --- a/components/dialog/dialog-v2.jsx +++ /dev/null @@ -1,369 +0,0 @@ -/* istanbul ignore file */ -import React, { useState, useRef, useEffect, useContext } from 'react'; -import ReactDOM from 'react-dom'; -import classNames from 'classnames'; -import Overlay from '@alifd/overlay'; - -import Inner from './inner'; -import Animate from '../animate'; -import zhCN from '../locale/zh-cn'; -import { log, func, dom, focus, guid } from '../util'; -import scrollLocker from './scroll-locker'; - -const { OverlayContext } = Overlay; -const noop = func.noop; - -const Dialog = props => { - if (!useState || !useRef || !useEffect) { - log.warning('need react version > 16.8.0'); - return null; - } - - const { - prefix = 'next-', - afterClose = noop, - hasMask = true, - autoFocus = false, - className, - title, - children, - footer, - footerAlign, - footerActions, - onOk = noop, - onCancel, - okProps, - cancelProps, - locale = zhCN.Dialog, - rtl, - visible: pvisible, - closeMode = ['close', 'esc'], - closeIcon, - animation = { in: 'fadeInUp', out: 'fadeOutUp' }, - cache, - wrapperStyle, - popupContainer = document.body, - dialogRender, - centered, - top = centered ? 40 : 100, - bottom = 40, - width = 520, - height, - isFullScreen, - overflowScroll = !isFullScreen, - minMargin, - onClose, - style, - wrapperClassName, - ...others - } = props; - - if ('isFullScreen' in props) { - log.deprecated('isFullScreen', 'overflowScroll', 'Dialog v2'); - } - if ('minMargin' in props) { - log.deprecated('minMargin', 'top/bottom', 'Dialog v2'); - } - - const [firstVisible, setFirst] = useState(pvisible || false); - const [visible, setVisible] = useState(pvisible); - const getContainer = - typeof popupContainer === 'string' - ? () => document.getElementById(popupContainer) - : typeof popupContainer !== 'function' - ? () => popupContainer - : popupContainer; - const [container, setContainer] = useState(getContainer()); - const dialogRef = useRef(null); - const wrapperRef = useRef(null); - const lastFocus = useRef(null); - const locker = useRef(null); - const [uuid] = useState(guid()); - const { setVisibleOverlayToParent, ...otherContext } = useContext(OverlayContext); - const childIDMap = useRef(new Map()); - const isAnimationEnd = useRef(false); - const [, forceUpdate] = useState(); - - const markAnimationEnd = state => { - isAnimationEnd.current = state; - forceUpdate({}); - }; - - let canCloseByEsc = false; - let canCloseByMask = false; - let closeable = false; - - const closeModeArray = Array.isArray(closeMode) ? closeMode : [closeMode]; - closeModeArray.forEach(mode => { - switch (mode) { - case 'esc': - canCloseByEsc = true; - break; - case 'mask': - canCloseByMask = true; - break; - case 'close': - closeable = true; - break; - } - }); - - // visible 受控 - useEffect(() => { - if ('visible' in props) { - setVisible(pvisible); - } - }, [pvisible]); - - // 打开遮罩后 document.body 滚动处理 - useEffect(() => { - if (visible && hasMask) { - const style = { - overflow: 'hidden', - }; - - if (dom.hasScroll(document.body)) { - const scrollWidth = dom.scrollbar().width; - if (scrollWidth) { - style.paddingRight = `${dom.getStyle(document.body, 'paddingRight') + dom.scrollbar().width}px`; - } - } - locker.current = scrollLocker.lock(document.body, style); - } - }, [visible && hasMask]); - - const handleClose = (targetType, e) => { - setVisibleOverlayToParent(uuid, null); - typeof onClose === 'function' && onClose(targetType, e); - }; - - const keydownEvent = e => { - if (e.keyCode === 27 && canCloseByEsc && !childIDMap.current.size) { - handleClose('esc', e); - } - }; - - // esc 键盘事件处理 - useEffect(() => { - if (visible && canCloseByEsc) { - document.body.addEventListener('keydown', keydownEvent, false); - return () => { - document.body.removeEventListener('keydown', keydownEvent, false); - }; - } - }, [visible && canCloseByEsc]); - - // 优化: 第一次加载并且 visible=false 的情况不挂载弹窗 - useEffect(() => { - !firstVisible && visible && setFirst(true); - }, [visible]); - - // container 异步加载情况 - useEffect(() => { - if (!container) { - setTimeout(() => { - setContainer(getContainer()); - }); - } - }, [container]); - - const handleExited = () => { - if (!isAnimationEnd.current) { - markAnimationEnd(true); - dom.setStyle(wrapperRef.current, 'display', 'none'); - scrollLocker.unlock(document.body, locker.current); - - if (autoFocus && lastFocus.current) { - try { - lastFocus.current.focus(); - } finally { - // ignore ... - } - lastFocus.current = null; - } - afterClose(); - } - }; - - useEffect(() => { - return () => { - handleExited(); - }; - }, []); - - if (firstVisible === false || !container) { - return null; - } - - if (!visible && !cache && isAnimationEnd.current) { - return null; - } - - const handleCancel = e => { - if (typeof onCancel === 'function') { - onCancel(e); - } else { - handleClose('cancelBtn', e); - } - }; - - const handleMaskClick = e => { - if (!canCloseByMask) { - return; - } - - if (e.type === 'click' && dialogRef.current) { - const dialogNode = ReactDOM.findDOMNode(dialogRef.current); - if (dialogNode && dialogNode.contains(e.target)) { - return; - } - } - - handleClose('maskClick', e); - }; - - const handleEnter = () => { - markAnimationEnd(false); - dom.setStyle(wrapperRef.current, 'display', ''); - }; - const handleEntered = () => { - if (autoFocus && dialogRef.current && dialogRef.current.bodyNode) { - const focusableNodes = focus.getFocusNodeList(dialogRef.current.bodyNode); - if (focusableNodes.length > 0 && focusableNodes[0]) { - lastFocus.current = document.activeElement; - focusableNodes[0].focus(); - } - } - setVisibleOverlayToParent(uuid, wrapperRef.current); - }; - - const wrapperCls = classNames({ - [`${prefix}overlay-wrapper`]: true, - [wrapperClassName]: !!wrapperClassName, - opened: visible, - }); - const dialogCls = classNames({ - [`${prefix}dialog-v2`]: true, - [className]: !!className, - }); - - const topStyle = {}; - if (centered) { - // 兼容 minMargin - if (!top && !bottom && minMargin) { - topStyle.marginTop = minMargin; - topStyle.marginBottom = minMargin; - } else { - top && (topStyle.marginTop = top); - bottom && (topStyle.marginBottom = bottom); - } - } else { - top && (topStyle.top = top); - bottom && (topStyle.paddingBottom = bottom); - } - - const innerStyle = style || {}; - if (overflowScroll && !innerStyle.maxHeight) { - innerStyle.maxHeight = `calc(100vh - ${top + bottom}px)`; - } - - const timeout = { - appear: 300, - enter: 300, - exit: 250, - }; - - let inner = ( - - handleClose('closeClick', ...args)} - closeIcon={closeIcon} - height={height} - width={width} - > - {children} - - - ); - - if (typeof dialogRender === 'function') { - inner = dialogRender(inner); - } - - const innerWrapperCls = classNames({ - [`${prefix}overlay-inner`]: true, - [`${prefix}dialog-wrapper`]: true, - [`${prefix}dialog-centered`]: centered, - }); - - const getVisibleOverlayFromChild = (id, node) => { - if (node) { - childIDMap.current.set(id, node); - } else { - childIDMap.current.delete(id); - } - // 让父级也感知 - setVisibleOverlayToParent(id, node); - }; - - return ( - - {ReactDOM.createPortal( -
    - {hasMask ? ( - -
    - - ) : null} - -
    - {centered ? ( - inner - ) : ( -
    - {inner} -
    - )} -
    -
    , - container - )} - - ); -}; - -export default Dialog; diff --git a/components/dialog/dialog-v2.tsx b/components/dialog/dialog-v2.tsx new file mode 100644 index 0000000000..5419c6f1f2 --- /dev/null +++ b/components/dialog/dialog-v2.tsx @@ -0,0 +1,384 @@ +/* istanbul ignore file */ +import React, { + useState, + useRef, + useEffect, + useContext, + type MouseEvent, + type CSSProperties, + type ReactNode, +} from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import Overlay from '@alifd/overlay'; + +import Inner from './inner'; +import Animate from '../animate'; +import zhCN from '../locale/zh-cn'; +import { log, func, dom, focus, guid } from '../util'; +import scrollLocker from './scroll-locker'; +import type { DialogV2Props } from './types'; +import type { CustomCSSStyle } from '../util/dom'; + +const { OverlayContext } = Overlay; +const noop = func.noop; + +const Dialog = (props: DialogV2Props) => { + if (!useState || !useRef || !useEffect) { + log.warning('need react version > 16.8.0'); + return null; + } + + const { + prefix = 'next-', + afterClose = noop, + hasMask = true, + autoFocus = false, + className, + title, + children, + footer, + footerAlign, + footerActions, + onOk = noop, + onCancel, + okProps, + cancelProps, + locale = zhCN.Dialog, + rtl, + visible: pvisible, + closeMode = ['close', 'esc'], + closeIcon, + animation = { in: 'fadeInUp', out: 'fadeOutUp' }, + cache, + wrapperStyle, + popupContainer = typeof document !== 'undefined' ? document.body : null, + dialogRender, + centered, + top = centered ? 40 : 100, + bottom = 40, + width = 520, + height, + isFullScreen, + overflowScroll = !isFullScreen, + minMargin, + onClose, + style, + wrapperClassName, + ...others + } = props; + + if ('isFullScreen' in props) { + log.deprecated('isFullScreen', 'overflowScroll', 'Dialog v2'); + } + if ('minMargin' in props) { + log.deprecated('minMargin', 'top/bottom', 'Dialog v2'); + } + + const [firstVisible, setFirst] = useState(pvisible || false); + const [visible, setVisible] = useState(pvisible); + const getContainer = + typeof popupContainer === 'string' + ? () => document.getElementById(popupContainer) + : typeof popupContainer !== 'function' + ? () => popupContainer + : popupContainer; + const [container, setContainer] = useState(getContainer()); + const dialogRef = useRef(null); + const wrapperRef = useRef(null); + const lastFocus = useRef(); + const locker = useRef(null); + const [uuid] = useState(guid()); + const { setVisibleOverlayToParent, ...otherContext } = useContext(OverlayContext); + const childIDMap = useRef(new Map()); + const isAnimationEnd = useRef(false); + const [, forceUpdate] = useState>(); + + const markAnimationEnd = (state: boolean) => { + isAnimationEnd.current = state; + forceUpdate({}); + }; + + let canCloseByEsc = false; + let canCloseByMask = false; + let closeable = false; + + const closeModeArray = Array.isArray(closeMode) ? closeMode : [closeMode]; + closeModeArray.forEach(mode => { + switch (mode) { + case 'esc': + canCloseByEsc = true; + break; + case 'mask': + canCloseByMask = true; + break; + case 'close': + closeable = true; + break; + } + }); + + // visible 受控 + useEffect(() => { + if ('visible' in props) { + setVisible(pvisible); + } + }, [pvisible]); + + // 打开遮罩后 document.body 滚动处理 + useEffect(() => { + if (visible && hasMask) { + const style: Partial = { + overflow: 'hidden', + }; + + if (dom.hasScroll(document.body)) { + const scrollWidth = dom.scrollbar().width; + if (scrollWidth) { + style.paddingRight = `${ + (dom.getStyle(document.body, 'paddingRight') as number) + + dom.scrollbar().width + }px`; + } + } + locker.current = scrollLocker.lock(document.body, style); + } + }, [visible && hasMask]); + + const handleClose = (targetType: string, e: MouseEvent | KeyboardEvent) => { + setVisibleOverlayToParent(uuid, null); + typeof onClose === 'function' && onClose(targetType, e); + }; + + const keydownEvent = (e: KeyboardEvent) => { + if (e.keyCode === 27 && canCloseByEsc && !childIDMap.current.size) { + handleClose('esc', e); + } + }; + + // esc 键盘事件处理 + useEffect(() => { + if (visible && canCloseByEsc) { + document.body.addEventListener('keydown', keydownEvent, false); + return () => { + document.body.removeEventListener('keydown', keydownEvent, false); + }; + } + }, [visible && canCloseByEsc]); + + // 优化: 第一次加载并且 visible=false 的情况不挂载弹窗 + useEffect(() => { + !firstVisible && visible && setFirst(true); + }, [visible]); + + // container 异步加载情况 + useEffect(() => { + if (!container) { + setTimeout(() => { + setContainer(getContainer()); + }); + } + }, [container]); + + const handleExited = () => { + if (!isAnimationEnd.current) { + markAnimationEnd(true); + dom.setStyle(wrapperRef.current!, 'display', 'none'); + scrollLocker.unlock(document.body, locker.current!); + + if (autoFocus && lastFocus.current) { + try { + lastFocus.current.focus(); + } finally { + // ignore ... + } + lastFocus.current = undefined; + } + afterClose(); + } + }; + + useEffect(() => { + return () => { + handleExited(); + }; + }, []); + + if (firstVisible === false || !container) { + return null; + } + + if (!visible && !cache && isAnimationEnd.current) { + return null; + } + + const handleCancel = (e: MouseEvent) => { + if (typeof onCancel === 'function') { + onCancel(e); + } else { + handleClose('cancelBtn', e); + } + }; + + const handleMaskClick = (e: MouseEvent) => { + if (!canCloseByMask) { + return; + } + + if (e.type === 'click' && dialogRef.current) { + const dialogNode = ReactDOM.findDOMNode(dialogRef.current); + if (dialogNode && dialogNode.contains(e.target as Element)) { + return; + } + } + + handleClose('maskClick', e); + }; + + const handleEnter = () => { + markAnimationEnd(false); + dom.setStyle(wrapperRef.current!, 'display', ''); + }; + const handleEntered = () => { + if (autoFocus && dialogRef.current && dialogRef.current.bodyNode) { + const focusableNodes = focus.getFocusNodeList(dialogRef.current.bodyNode); + if (focusableNodes.length > 0 && focusableNodes[0]) { + lastFocus.current = document.activeElement as HTMLElement; + focusableNodes[0].focus(); + } + } + setVisibleOverlayToParent(uuid, wrapperRef.current); + }; + + const wrapperCls = classNames({ + [`${prefix}overlay-wrapper`]: true, + [wrapperClassName!]: !!wrapperClassName, + opened: visible, + }); + const dialogCls = classNames({ + [`${prefix}dialog-v2`]: true, + [className!]: !!className, + }); + + const topStyle: CSSProperties = {}; + if (centered) { + // 兼容 minMargin + if (!top && !bottom && minMargin) { + topStyle.marginTop = minMargin; + topStyle.marginBottom = minMargin; + } else { + top && (topStyle.marginTop = top); + bottom && (topStyle.marginBottom = bottom); + } + } else { + top && (topStyle.top = top); + bottom && (topStyle.paddingBottom = bottom); + } + + const innerStyle = style || {}; + if (overflowScroll && !innerStyle.maxHeight) { + innerStyle.maxHeight = `calc(100vh - ${top + bottom}px)`; + } + + const timeout = { + appear: 300, + enter: 300, + exit: 250, + }; + + let inner: ReactNode = ( + + handleClose('closeClick', ...args)} + closeIcon={closeIcon} + height={height} + width={width} + > + {children} + + + ); + + if (typeof dialogRender === 'function') { + inner = dialogRender(inner); + } + + const innerWrapperCls = classNames({ + [`${prefix}overlay-inner`]: true, + [`${prefix}dialog-wrapper`]: true, + [`${prefix}dialog-centered`]: centered, + }); + + const getVisibleOverlayFromChild = (id: string, node: Element) => { + if (node) { + childIDMap.current.set(id, node); + } else { + childIDMap.current.delete(id); + } + // 让父级也感知 + setVisibleOverlayToParent(id, node); + }; + + return ( + + {ReactDOM.createPortal( +
    + {hasMask ? ( + +
    + + ) : null} + +
    + {centered ? ( + inner + ) : ( +
    + {inner} +
    + )} +
    +
    , + container + )} + + ); +}; + +Dialog.displayName = 'Dialog'; + +export default Dialog; diff --git a/components/dialog/dialog.jsx b/components/dialog/dialog.jsx deleted file mode 100644 index cca3e58440..0000000000 --- a/components/dialog/dialog.jsx +++ /dev/null @@ -1,490 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import Overlay from '../overlay'; -import zhCN from '../locale/zh-cn'; -import { focus, obj, func, events, dom, env } from '../util'; -import Inner from './inner'; - -const noop = () => {}; -const { limitTabRange } = focus; -const { bindCtx } = func; -const { pickOthers } = obj; -const { getStyle, setStyle } = dom; - -// [fix issue #1609](https://github.com/alibaba-fusion/next/issues/1609) -// https://stackoverflow.com/questions/19717907/getcomputedstyle-reporting-different-heights-between-chrome-safari-firefox-and-i -function _getSize(dom, name) { - const boxSizing = getStyle(dom, 'boxSizing'); - - if (env.ieVersion && ['width', 'height'].indexOf(name) !== -1 && boxSizing === 'border-box') { - return parseFloat(dom.getBoundingClientRect()[name].toFixed(1)); - } else { - return getStyle(dom, name); - } -} - -/** - * Dialog - */ -export default class Dialog extends Component { - static propTypes = { - prefix: PropTypes.string, - pure: PropTypes.bool, - rtl: PropTypes.bool, - className: PropTypes.string, - /** - * 是否显示 - */ - visible: PropTypes.bool, - /** - * 标题 - */ - title: PropTypes.node, - /** - * 内容 - */ - children: PropTypes.node, - /** - * 底部内容,设置为 false,则不进行显示 - * @default [, ] - */ - footer: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]), - /** - * 底部按钮的对齐方式 - */ - footerAlign: PropTypes.oneOf(['left', 'center', 'right']), - /** - * 指定确定按钮和取消按钮是否存在以及如何排列,

    **可选值**: - * ['ok', 'cancel'](确认取消按钮同时存在,确认按钮在左) - * ['cancel', 'ok'](确认取消按钮同时存在,确认按钮在右) - * ['ok'](只存在确认按钮) - * ['cancel'](只存在取消按钮) - */ - footerActions: PropTypes.array, - /** - * 在点击确定按钮时触发的回调函数 - * @param {Object} event 点击事件对象 - */ - onOk: PropTypes.func, - /** - * 在点击取消/关闭按钮时触发的回调函数 - * @param {Object} event 点击事件对象, event.triggerType=esc|closeIcon 可区分点击来源 - */ - onCancel: PropTypes.func, - /** - * 应用于确定按钮的属性对象 - */ - okProps: PropTypes.object, - /** - * 应用于取消按钮的属性对象 - */ - cancelProps: PropTypes.object, - /** - * [推荐]1.21.x 支持控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 ['close','esc','mask'], [] - * @version 1.21 - */ - closeMode: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.oneOf(['close', 'mask', 'esc'])), - PropTypes.oneOf(['close', 'mask', 'esc']), - ]), - /** - * 隐藏时是否保留子节点,不销毁 (低版本通过 overlayProps 实现) - * @version 1.23 - */ - cache: PropTypes.bool, - /** - * 对话框关闭后触发的回调函数, 如果有动画,则在动画结束后触发 - */ - afterClose: PropTypes.func, - /** - * 是否显示遮罩 - */ - hasMask: PropTypes.bool, - /** - * 显示隐藏时动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画。 请参考 Animate 组件的文档获取可用的动画名 - * @default { in: 'expandInDown', out: 'expandOutUp' } - */ - animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - /** - * 对话框弹出时是否自动获得焦点 - */ - autoFocus: PropTypes.bool, - /** - * [v2废弃] 透传到弹层组件的属性对象 - */ - overlayProps: PropTypes.object, - /** - * 自定义国际化文案对象 - * @property {String} ok 确认按钮文案 - * @property {String} cancel 取消按钮文案 - */ - locale: PropTypes.object, - // Do not remove this, it's for - // see https://github.com/alibaba-fusion/next/issues/1508 - /** - * 自定义弹窗挂载位置 - */ - popupContainer: PropTypes.any, - /** - * 对话框的高度样式属性 - */ - height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** - * 开启 v2 版本弹窗 - */ - v2: PropTypes.bool, - /** - * [v2] 弹窗宽度 - * @version 1.25 - */ - width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** - * [v2] 弹窗上边距。默认 100,设置 centered=true 后默认 40 - * @version 1.25 - */ - top: PropTypes.number, - /** - * [v2] 弹窗下边距 - * @version 1.25 - */ - bottom: PropTypes.number, - /** - * [v2] 定制关闭按钮 icon - * @version 1.25 - */ - closeIcon: PropTypes.node, - /** - * [v2] 弹窗居中对齐 - * @version 1.25 - */ - centered: PropTypes.bool, - /** - * [v2] 对话框高度超过浏览器视口高度时,对话框是否展示滚动条。关闭此功后对话框会随高度撑开页面。 - * @version 1.25 - */ - overflowScroll: PropTypes.bool, - /** - * [v2] 最外包裹层 className - * @version 1.26 - */ - wrapperClassName: PropTypes.string, - /** - * [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 'close,esc,mask' - * 如果设置为 true,则以上关闭方式全部生效 - * 如果设置为 false,则以上关闭方式全部失效 - */ - closeable: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - /** - * 点击对话框关闭按钮时触发的回调函数 - * @param {String} trigger 关闭触发行为的描述字符串 - * @param {Object} event 关闭时事件对象 - */ - onClose: PropTypes.func, - /** - * [v2废弃] 对话框对齐方式, 具体见Overlay文档 - */ - align: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - /** - * [v2废弃] 是否撑开页面。 v2 改用 overflowScroll - */ - isFullScreen: PropTypes.bool, - /** - * [v2废弃] 是否在对话框重新渲染时及时更新对话框位置,一般用于对话框高度变化后依然能保证原来的对齐方式 - */ - shouldUpdatePosition: PropTypes.bool, - /** - * [v2废弃] 对话框距离浏览器顶部和底部的最小间距,align 被设置为 'cc cc' 并且 isFullScreen 被设置为 true 时不生效 - */ - minMargin: PropTypes.number, - /** - * 去除body内间距 - * @version 1.26 - */ - noPadding: PropTypes.bool, - }; - - static defaultProps = { - prefix: 'next-', - pure: false, - visible: false, - footerAlign: 'right', - footerActions: ['ok', 'cancel'], - onOk: noop, - onCancel: noop, - cache: false, - okProps: {}, - cancelProps: {}, - closeable: 'esc,close', - onClose: noop, - afterClose: noop, - centered: false, - hasMask: true, - animation: { - in: 'fadeInUp', - out: 'fadeOutUp', - }, - autoFocus: false, - align: 'cc cc', - isFullScreen: false, - overflowScroll: true, - shouldUpdatePosition: false, - minMargin: 40, - bottom: 40, - overlayProps: {}, - locale: zhCN.Dialog, - noPadding: false, - }; - - constructor(props, context) { - super(props, context); - bindCtx(this, ['onKeyDown', 'beforePosition', 'adjustPosition', 'getOverlayRef']); - } - - componentDidMount() { - events.on(document, 'keydown', this.onKeyDown); - if (!this.useCSSToPosition()) { - this.adjustPosition(); - } - } - - componentWillUnmount() { - events.off(document, 'keydown', this.onKeyDown); - } - - useCSSToPosition() { - const { align, isFullScreen } = this.props; - return align === 'cc cc' && isFullScreen; - } - - onKeyDown(e) { - if (this.overlay) { - const node = this.getInnerNode(); - if (node) { - limitTabRange(node, e); - } - } - } - - beforePosition() { - if (this.props.visible && this.overlay) { - const inner = this.getInner(); - if (inner) { - const node = this.getInnerNode(); - if (this._lastDialogHeight !== _getSize(node, 'height')) { - this.revertSize(inner.bodyNode); - } - } - } - } - - adjustPosition() { - if (this.props.visible && this.overlay) { - const inner = this.getInner(); - if (inner) { - const node = this.getInnerNode(); - - let top = getStyle(node, 'top'); - const minMargin = this.props.minMargin; - if (top < minMargin) { - top = minMargin; - setStyle(node, 'top', `${minMargin}px`); - } - - const height = _getSize(node, 'height'); - const viewportHeight = window.innerHeight || document.documentElement.clientHeight; - - if ( - viewportHeight < height + top * 2 - 1 || // 分辨率和精确度的原因 高度计算的时候 可能会有1px内的偏差 - this.props.height - ) { - this.adjustSize(inner, node, Math.min(height, viewportHeight - top * 2)); - } else { - this.revertSize(inner.bodyNode); - } - - this._lastDialogHeight = height; - } - } - } - - adjustSize(inner, node, expectHeight) { - const { headerNode, bodyNode, footerNode } = inner; - const [headerHeight, footerHeight] = [headerNode, footerNode].map(node => - node ? _getSize(node, 'height') : 0 - ); - const padding = ['padding-top', 'padding-bottom'].reduce((sum, attr) => sum + getStyle(node, attr), 0); - - let maxBodyHeight = expectHeight - headerHeight - footerHeight - padding; - - if (maxBodyHeight < 0) { - maxBodyHeight = 1; - } - - if (bodyNode) { - this.dialogBodyStyleMaxHeight = bodyNode.style.maxHeight; - this.dialogBodyStyleOverflowY = bodyNode.style.overflowY; - - setStyle(bodyNode, { - 'max-height': `${maxBodyHeight}px`, - 'overflow-y': 'auto', - }); - } - } - - revertSize(bodyNode) { - setStyle(bodyNode, { - 'max-height': this.dialogBodyStyleMaxHeight, - 'overflow-y': this.dialogBodyStyleOverflowY, - }); - } - - mapcloseableToConfig(closeable) { - return ['esc', 'close', 'mask'].reduce((ret, option) => { - const key = option.charAt(0).toUpperCase() + option.substr(1); - const value = typeof closeable === 'boolean' ? closeable : closeable.split(',').indexOf(option) > -1; - - if (option === 'esc' || option === 'mask') { - ret[`canCloseBy${key}`] = value; - } else { - ret[`canCloseBy${key}Click`] = value; - } - - return ret; - }, {}); - } - - getOverlayRef(ref) { - this.overlay = ref; - } - - getInner() { - return this.overlay.getInstance().getContent(); - } - - getInnerNode() { - return this.overlay.getInstance().getContentNode(); - } - - renderInner(closeable) { - const { - prefix, - className, - title, - children, - footer, - footerAlign, - footerActions, - onOk, - onCancel, - okProps, - cancelProps, - onClose, - locale, - visible, - rtl, - height, - noPadding, - } = this.props; - const others = pickOthers(Object.keys(Dialog.propTypes), this.props); - - return ( - - {children} - - ); - } - - render() { - const { - prefix, - visible, - hasMask, - animation, - autoFocus, - closeable, - closeMode, - onClose, - afterClose, - shouldUpdatePosition, - align, - popupContainer, - cache, - overlayProps, - rtl, - } = this.props; - - const useCSS = this.useCSSToPosition(); - const newCloseable = - 'closeMode' in this.props ? (Array.isArray(closeMode) ? closeMode.join(',') : closeMode) : closeable; - const { canCloseByCloseClick, ...closeConfig } = this.mapcloseableToConfig(newCloseable); - const newOverlayProps = { - disableScroll: true, - container: popupContainer, - cache, - ...overlayProps, - prefix, - visible, - animation, - hasMask, - autoFocus, - afterClose, - ...closeConfig, - canCloseByOutSideClick: false, - align: useCSS ? false : align, - onRequestClose: onClose, - needAdjust: false, - ref: this.getOverlayRef, - rtl, - maskClass: useCSS ? `${prefix}dialog-container` : '', - isChildrenInMask: useCSS && hasMask, - }; - if (!useCSS) { - newOverlayProps.beforePosition = this.beforePosition; - newOverlayProps.onPosition = this.adjustPosition; - newOverlayProps.shouldUpdatePosition = shouldUpdatePosition; - } - - const inner = this.renderInner(canCloseByCloseClick); - - // useCSS && hasMask : isFullScreen 并且 有mask的模式下,为了解决 next-overlay-backdrop 覆盖mask,使得点击mask关闭页面的功能不生效的问题,需要开启 Overlay 的 isChildrenInMask 功能,并且把 next-dialog-container 放到 next-overlay-backdrop上 - // useCSS && !hasMask : isFullScreen 并且 没有mask的情况下,需要关闭 isChildrenInMask 功能,以防止children不渲染 - // 其他模式下维持 mask 与 children 同级的关系 - return ( - - {useCSS && !hasMask ? ( -
    - {inner} -
    - ) : ( - inner - )} -
    - ); - } -} diff --git a/components/dialog/dialog.tsx b/components/dialog/dialog.tsx new file mode 100644 index 0000000000..d19dd4d59d --- /dev/null +++ b/components/dialog/dialog.tsx @@ -0,0 +1,382 @@ +import React, { Component, type ComponentPropsWithRef } from 'react'; +import PropTypes from 'prop-types'; +import Overlay from '../overlay'; +import zhCN from '../locale/zh-cn'; +import { focus, obj, func, events, dom, env } from '../util'; +import Inner from './inner'; +import { type CustomCSSStyleKey } from '../util/dom'; +import type { DialogV1Props } from './types'; + +const noop = () => {}; +const { limitTabRange } = focus; +const { bindCtx } = func; +const { pickOthers } = obj; +const { getStyle, setStyle } = dom; + +function isWidthOrHeight(name: string): name is 'width' | 'height' { + return ['width', 'height'].indexOf(name) !== -1; +} + +// [fix issue #1609](https://github.com/alibaba-fusion/next/issues/1609) +// https://stackoverflow.com/questions/19717907/getcomputedstyle-reporting-different-heights-between-chrome-safari-firefox-and-i +function _getSize(dom: HTMLElement, name: CustomCSSStyleKey) { + const boxSizing = getStyle(dom, 'boxSizing'); + + if (env.ieVersion && isWidthOrHeight(name) && boxSizing === 'border-box') { + return parseFloat(dom.getBoundingClientRect()[name].toFixed(1)); + } else { + return getStyle(dom, name); + } +} + +/** + * Dialog + */ +export default class Dialog extends Component { + static propTypes = { + prefix: PropTypes.string, + pure: PropTypes.bool, + rtl: PropTypes.bool, + className: PropTypes.string, + visible: PropTypes.bool, + title: PropTypes.node, + children: PropTypes.node, + footer: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]), + footerAlign: PropTypes.oneOf(['left', 'center', 'right']), + footerActions: PropTypes.array, + onOk: PropTypes.func, + onCancel: PropTypes.func, + okProps: PropTypes.object, + cancelProps: PropTypes.object, + closeMode: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOf(['close', 'mask', 'esc'])), + PropTypes.oneOf(['close', 'mask', 'esc']), + ]), + cache: PropTypes.bool, + afterClose: PropTypes.func, + hasMask: PropTypes.bool, + animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + autoFocus: PropTypes.bool, + overlayProps: PropTypes.object, + locale: PropTypes.object, + // Do not remove this, it's for + // see https://github.com/alibaba-fusion/next/issues/1508 + popupContainer: PropTypes.any, + height: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + v2: PropTypes.bool, + width: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + top: PropTypes.number, + bottom: PropTypes.number, + closeIcon: PropTypes.node, + centered: PropTypes.bool, + overflowScroll: PropTypes.bool, + wrapperClassName: PropTypes.string, + closeable: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + onClose: PropTypes.func, + align: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + isFullScreen: PropTypes.bool, + shouldUpdatePosition: PropTypes.bool, + minMargin: PropTypes.number, + noPadding: PropTypes.bool, + }; + + static defaultProps = { + prefix: 'next-', + pure: false, + visible: false, + footerAlign: 'right', + footerActions: ['ok', 'cancel'], + onOk: noop, + onCancel: noop, + cache: false, + okProps: {}, + cancelProps: {}, + closeable: 'esc,close', + onClose: noop, + afterClose: noop, + centered: false, + hasMask: true, + animation: { + in: 'fadeInUp', + out: 'fadeOutUp', + }, + autoFocus: false, + align: 'cc cc', + isFullScreen: false, + overflowScroll: true, + shouldUpdatePosition: false, + minMargin: 40, + bottom: 40, + overlayProps: {}, + locale: zhCN.Dialog, + noPadding: false, + }; + + static displayName = 'Dialog'; + overlay: InstanceType | null; + private _lastDialogHeight: string | number; + dialogBodyStyleMaxHeight: string; + dialogBodyStyleOverflowY: string; + + constructor(props: DialogV1Props) { + super(props); + bindCtx(this, ['onKeyDown', 'beforePosition', 'adjustPosition', 'getOverlayRef']); + } + + componentDidMount() { + events.on(document, 'keydown', this.onKeyDown); + if (!this.useCSSToPosition()) { + this.adjustPosition(); + } + } + + componentWillUnmount() { + events.off(document, 'keydown', this.onKeyDown); + } + + useCSSToPosition() { + const { align, isFullScreen } = this.props; + return align === 'cc cc' && isFullScreen; + } + + onKeyDown(e: KeyboardEvent) { + if (this.overlay) { + const node = this.getInnerNode(); + if (node) { + limitTabRange(node, e); + } + } + } + + beforePosition() { + if (this.props.visible && this.overlay) { + const inner = this.getInner(); + if (inner) { + const node = this.getInnerNode(); + if (this._lastDialogHeight !== _getSize(node!, 'height')) { + this.revertSize(inner.bodyNode); + } + } + } + } + + adjustPosition() { + if (this.props.visible && this.overlay) { + const inner = this.getInner(); + if (inner) { + const node = this.getInnerNode(); + + let top = getStyle(node!, 'top') as number; + const minMargin = this.props.minMargin; + if (top < minMargin!) { + top = minMargin!; + setStyle(node, 'top', `${minMargin}px`); + } + + const height = _getSize(node!, 'height') as number; + const viewportHeight = window.innerHeight || document.documentElement.clientHeight; + + if ( + viewportHeight < height + top * 2 - 1 || // 分辨率和精确度的原因 高度计算的时候 可能会有1px内的偏差 + this.props.height + ) { + this.adjustSize(inner, node!, Math.min(height, viewportHeight - top * 2)); + } else { + this.revertSize(inner.bodyNode); + } + + this._lastDialogHeight = height; + } + } + } + + adjustSize( + inner: { headerNode: HTMLElement; bodyNode: HTMLElement; footerNode: HTMLDivElement }, + node: HTMLElement, + expectHeight: number + ) { + const { headerNode, bodyNode, footerNode } = inner; + const [headerHeight, footerHeight] = [headerNode, footerNode].map(node => + node ? (_getSize(node, 'height') as number) : 0 + ); + const padding = ['padding-top', 'padding-bottom'].reduce( + (sum, attr: CustomCSSStyleKey) => sum + (getStyle(node, attr) as number), + 0 + ); + + let maxBodyHeight = expectHeight - headerHeight - footerHeight - padding; + + if (maxBodyHeight < 0) { + maxBodyHeight = 1; + } + + if (bodyNode) { + this.dialogBodyStyleMaxHeight = bodyNode.style.maxHeight; + this.dialogBodyStyleOverflowY = bodyNode.style.overflowY; + + setStyle(bodyNode, { + 'max-height': `${maxBodyHeight}px`, + 'overflow-y': 'auto', + }); + } + } + + revertSize(bodyNode: HTMLElement) { + setStyle(bodyNode, { + 'max-height': this.dialogBodyStyleMaxHeight, + 'overflow-y': this.dialogBodyStyleOverflowY, + }); + } + + mapcloseableToConfig(closeable: NonNullable) { + return ['esc', 'close', 'mask'].reduce( + (ret, option) => { + const key = option.charAt(0).toUpperCase() + option.substr(1); + const value = + typeof closeable === 'boolean' + ? closeable + : closeable.split(',').indexOf(option) > -1; + + if (option === 'esc' || option === 'mask') { + ret[`canCloseBy${key}`] = value; + } else { + ret[`canCloseBy${key}Click`] = value; + } + + return ret; + }, + {} as Record + ); + } + + getOverlayRef(ref: InstanceType | null) { + this.overlay = ref; + } + + getInner() { + return this.overlay!.getInstance().getContent(); + } + + getInnerNode() { + return this.overlay!.getInstance().getContentNode(); + } + + renderInner(closeable: boolean) { + const { + prefix, + className, + title, + children, + footer, + footerAlign, + footerActions, + onOk, + onCancel, + okProps, + cancelProps, + onClose, + locale, + visible, + rtl, + height, + noPadding, + } = this.props; + const others = pickOthers(Object.keys(Dialog.propTypes), this.props); + + return ( + + {children} + + ); + } + + render() { + const { + prefix, + visible, + hasMask, + animation, + autoFocus, + closeable, + closeMode, + onClose, + afterClose, + shouldUpdatePosition, + align, + popupContainer, + cache, + overlayProps, + rtl, + } = this.props; + + const useCSS = this.useCSSToPosition(); + const newCloseable: DialogV1Props['closeable'] = + 'closeMode' in this.props + ? Array.isArray(closeMode) + ? (closeMode.join(',') as DialogV1Props['closeable']) + : closeMode + : closeable; + const { canCloseByCloseClick, ...closeConfig } = this.mapcloseableToConfig(newCloseable!); + const newOverlayProps: ComponentPropsWithRef = { + disableScroll: true, + container: popupContainer, + cache, + ...overlayProps, + prefix, + visible, + animation, + hasMask, + autoFocus, + afterClose, + ...closeConfig, + canCloseByOutSideClick: false, + align: (useCSS ? false : align) as string, + onRequestClose: onClose, + needAdjust: false, + ref: this.getOverlayRef, + rtl, + maskClass: useCSS ? `${prefix}dialog-container` : '', + isChildrenInMask: useCSS && hasMask, + }; + if (!useCSS) { + newOverlayProps.beforePosition = this.beforePosition; + newOverlayProps.onPosition = this.adjustPosition; + newOverlayProps.shouldUpdatePosition = shouldUpdatePosition; + } + + const inner = this.renderInner(canCloseByCloseClick); + + // useCSS && hasMask : isFullScreen 并且 有 mask 的模式下,为了解决 next-overlay-backdrop 覆盖 mask,使得点击 mask 关闭页面的功能不生效的问题,需要开启 Overlay 的 isChildrenInMask 功能,并且把 next-dialog-container 放到 next-overlay-backdrop 上 + // useCSS && !hasMask : isFullScreen 并且 没有 mask 的情况下,需要关闭 isChildrenInMask 功能,以防止 children 不渲染 + // 其他模式下维持 mask 与 children 同级的关系 + return ( + + {useCSS && !hasMask ? ( +
    + {inner} +
    + ) : ( + inner + )} +
    + ); + } +} diff --git a/components/dialog/index.d.ts b/components/dialog/index.d.ts deleted file mode 100644 index 0e607adcd6..0000000000 --- a/components/dialog/index.d.ts +++ /dev/null @@ -1,219 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; -import { ButtonProps } from '../button'; -import { OverlayProps } from '../overlay'; - -export type CloseMode = 'close' | 'mask' | 'esc'; -interface HTMLAttributesWeak extends React.HTMLAttributes { - title?: any; -} - -export interface DialogProps extends Omit, CommonProps { - /** - * 是否显示 - */ - visible?: boolean; - - /** - * 标题 - */ - title?: React.ReactNode; - - /** - * 内容 - */ - children?: React.ReactNode; - - /** - * 底部内容,设置为 false,则不进行显示 - */ - footer?: boolean | React.ReactNode; - - /** - * 底部按钮的对齐方式 - */ - footerAlign?: 'left' | 'center' | 'right'; - - /** - * 指定确定按钮和取消按钮是否存在以及如何排列,

    **可选值**: - * ['ok', 'cancel'](确认取消按钮同时存在,确认按钮在左) - * ['cancel', 'ok'](确认取消按钮同时存在,确认按钮在右) - * ['ok'](只存在确认按钮) - * ['cancel'](只存在取消按钮) - */ - footerActions?: Array; - - /** - * 隐藏时是否保留子节点,不销毁 - */ - cache?: boolean; - - /** - * 在点击确定按钮时触发的回调函数 - */ - onOk?: (event: React.MouseEvent) => void; - - /** - * 在点击取消按钮时触发的回调函数 - */ - onCancel?: (event: React.MouseEvent) => void; - - /** - * 应用于确定按钮的属性对象 - */ - okProps?: ButtonProps; - - /** - * 应用于取消按钮的属性对象 - */ - cancelProps?: ButtonProps; - - /** - * [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 'close,esc,mask' - * 如果设置为 true,则以上关闭方式全部生效 - * 如果设置为 false,则以上关闭方式全部失效 - */ - closeable?: 'close' | 'mask' | 'esc' | boolean | 'close,mask' | 'close,esc' | 'mask,esc'; - /** - * [推荐]控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 ['close','esc','mask'], [] - */ - closeMode?: CloseMode[] | 'close' | 'mask' | 'esc'; - - /** - * 对话框关闭时触发的回调函数 - */ - onClose?: (trigger: string, event: React.MouseEvent) => void; - - /** - * 对话框关闭后触发的回调函数, 如果有动画,则在动画结束后触发 - */ - afterClose?: () => void; - - /** - * 是否显示遮罩 - */ - hasMask?: boolean; - - /** - * 显示隐藏时动画的播放方式 - */ - animation?: any | boolean; - - /** - * 对话框弹出时是否自动获得焦点 - */ - autoFocus?: boolean; - - /** - * [v2废弃] 对话框对齐方式, 具体见Overlay文档 - * @deprecated - */ - align?: string | boolean; - /** - * [v2废弃] 当对话框高度超过浏览器视口高度时,是否显示所有内容而不是出现滚动条以保证对话框完整显示在浏览器视口内,该属性仅在对话框垂直水平居中时生效,即 align 被设置为 'cc cc' 时 - * @deprecated - */ - isFullScreen?: boolean; - - /** - * [v2废弃] 是否在对话框重新渲染时及时更新对话框位置,一般用于对话框高度变化后依然能保证原来的对齐方式 - * @deprecated - */ - shouldUpdatePosition?: boolean; - - /** - * [v2废弃] 对话框距离浏览器顶部和底部的最小间距,align 被设置为 'cc cc' 并且 isFullScreen 被设置为 true 时不生效 - * @deprecated - */ - minMargin?: number; - /** - * 透传到弹层组件的属性对象 - */ - overlayProps?: OverlayProps; - - /** - * 自定义国际化文案对象 - */ - locale?: { - ok: string; - cancel: string; - }; - - /** - * 对话框的高度样式属性 - */ - height?: string | number; - popupContainer?: string | HTMLElement | ((target: HTMLElement) => HTMLElement); - /** - * 开启 v2 版本弹窗 - */ - v2?: boolean; - /** - * [v2] 定制关闭按钮 icon - */ - closeIcon?: React.ReactNode; - /** - * [v2] 弹窗宽度 v2 生效 - */ - width?: string | number; - /** - * [v2] 弹窗上边距。默认 100,设置 centered=true 后默认 40 - */ - top?: number; - /** - * [v2] 弹窗下边距, 默认 40 - */ - bottom?: number; - /** - * [v2] 对话框高度超过浏览器视口高度时,对话框是否展示滚动条。关闭此功后对话框会随高度撑开页面 - */ - overflowScroll?: boolean; - /** - * [v2] 弹窗居中对齐 - */ - centered?: boolean; - /** - * [v2] 自定义渲染弹窗 - */ - dialogRender?: (modal: React.ReactNode) => React.ReactNode; - /** - * [v2] 最外包裹层 className - */ - wrapperClassName?: string; -} - -export interface QuickShowConfig extends DialogProps { - prefix?: string; - type?: 'alert' | 'confirm'; - messageProps?: object; - content?: React.ReactNode; - onOk?: () => void; - onCancel?: () => void; - okProps?: object; - needWrapper?: boolean; -} - -export interface QuickShowRet { - hide: () => void; -} - -export default class Dialog extends React.Component { - static show(config: QuickShowConfig): QuickShowRet; - static alert(config: QuickShowConfig): QuickShowRet; - static confirm(config: QuickShowConfig): QuickShowRet; - static success(config: QuickShowConfig): QuickShowRet; - static error(config: QuickShowConfig): QuickShowRet; - static warning(config: QuickShowConfig): QuickShowRet; - static notice(config: QuickShowConfig): QuickShowRet; - static help(config: QuickShowConfig): QuickShowRet; -} diff --git a/components/dialog/index.jsx b/components/dialog/index.jsx deleted file mode 100644 index 558676b343..0000000000 --- a/components/dialog/index.jsx +++ /dev/null @@ -1,115 +0,0 @@ -import React from 'react'; -import ConfigProvider from '../config-provider'; -import { log } from '../util'; -import Dialog1 from './dialog'; -import Dialog2 from './dialog-v2'; - -import Inner from './inner'; -import { show, alert, confirm, withContext, success, error, notice, warning, help } from './show'; - -class Dialog extends React.Component { - render() { - const { v2, ...others } = this.props; - if (v2) { - return ; - } else { - return ; - } - } -} - -Dialog.Inner = Inner; -Dialog.show = config => { - const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); - if (warning !== false) { - config = processProps(config, log.deprecated); - } - return show(config); -}; -Dialog.alert = config => { - const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); - if (warning !== false) { - config = processProps(config, log.deprecated); - } - return alert(config); -}; -Dialog.confirm = config => { - const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); - if (warning !== false) { - config = processProps(config, log.deprecated); - } - return confirm(config); -}; -Dialog.success = config => success(config); -Dialog.error = config => error(config); -Dialog.notice = config => notice(config); -Dialog.warning = config => warning(config); -Dialog.help = config => help(config); - -Dialog.withContext = withContext; - -/* istanbul ignore next */ -function processProps(props, deprecated) { - if ('closable' in props) { - deprecated('closable', 'closeable', 'Dialog'); - const { closable, ...others } = props; - props = { closeable: closable, ...others }; - } - - if ('v2' in props) { - const nProps = { ...props }; - if ('align' in props) { - delete nProps.align; - deprecated('align', 'centered', ''); - } - if ('shouldUpdatePosition' in props) { - delete nProps.shouldUpdatePosition; - log.warning(`Warning: [ shouldUpdatePosition ] is deprecated at [ ]`); - } - if ('minMargin' in props) { - // delete nProps.minMargin; - deprecated('minMargin', 'top/bottom', ''); - } - if ('isFullScreen' in props) { - props.overFlowScroll = !props.isFullScreen; - delete nProps.isFullScreen; - deprecated('isFullScreen', 'overFlowScroll', ''); - } - - return nProps; - } - - const overlayPropNames = [ - 'target', - 'offset', - 'beforeOpen', - 'onOpen', - 'afterOpen', - 'beforePosition', - 'onPosition', - 'cache', - 'safeNode', - 'wrapperClassName', - 'container', - ]; - overlayPropNames.forEach(name => { - if (name in props) { - deprecated(name, `overlayProps.${name}`, 'Dialog'); - const { overlayProps, ...others } = props; - const newOverlayProps = { - [name]: props[name], - ...(overlayProps || {}), - }; - delete others[name]; - props = { overlayProps: newOverlayProps, ...others }; - } - }); - - return props; -} - -export default ConfigProvider.config(Dialog, { - transform: (props, deprecated) => { - return processProps(props, deprecated); - }, -}); diff --git a/components/dialog/index.tsx b/components/dialog/index.tsx new file mode 100644 index 0000000000..0a4f6abfb1 --- /dev/null +++ b/components/dialog/index.tsx @@ -0,0 +1,117 @@ +import React from 'react'; +import ConfigProvider from '../config-provider'; +import { log } from '../util'; +import Dialog1 from './dialog'; +import Dialog2 from './dialog-v2'; + +import Inner from './inner'; +import { show, alert, confirm, withContext, success, error, notice, warning, help } from './show'; +import type { DialogProps, InnerProps, ShowConfig, ShowConfigV1, ShowConfigV2 } from './types'; + +export type { DialogProps, ShowConfig, InnerProps, ShowConfigV1, ShowConfigV2 }; + +function processProps(props: Record, deprecated: typeof log.deprecated) { + if ('closable' in props) { + deprecated('closable', 'closeable', 'Dialog'); + const { closable, ...others } = props; + props = { closeable: closable, ...others }; + } + + if ('v2' in props) { + const nProps = { ...props }; + if ('align' in props) { + delete nProps.align; + deprecated('align', 'centered', ''); + } + if ('shouldUpdatePosition' in props) { + delete nProps.shouldUpdatePosition; + log.warning(`Warning: [ shouldUpdatePosition ] is deprecated at [ ]`); + } + if ('minMargin' in props) { + // delete nProps.minMargin; + deprecated('minMargin', 'top/bottom', ''); + } + if ('isFullScreen' in props) { + props.overFlowScroll = !props.isFullScreen; + delete nProps.isFullScreen; + deprecated('isFullScreen', 'overFlowScroll', ''); + } + + return nProps; + } + + const overlayPropNames = [ + 'target', + 'offset', + 'beforeOpen', + 'onOpen', + 'afterOpen', + 'beforePosition', + 'onPosition', + 'cache', + 'safeNode', + 'wrapperClassName', + 'container', + ]; + overlayPropNames.forEach(name => { + if (name in props) { + deprecated(name, `overlayProps.${name}`, 'Dialog'); + const { overlayProps, ...others } = props; + const newOverlayProps = { + [name]: props[name], + ...(overlayProps || {}), + }; + delete others[name]; + props = { overlayProps: newOverlayProps, ...others }; + } + }); + + return props; +} + +class Dialog extends React.Component { + static displayName = 'Dialog'; + static Inner = Inner; + static withContext = withContext; + static show = (config: ShowConfig) => { + const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); + if (warning !== false) { + config = processProps(config as Record, log.deprecated); + } + return show(config); + }; + static alert = (config: ShowConfig) => { + const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); + if (warning !== false) { + config = processProps(config as Record, log.deprecated); + } + return alert(config); + }; + static confirm = (config: ShowConfig) => { + const { warning } = ConfigProvider.getContextProps(config, 'Dialog'); + if (warning !== false) { + config = processProps(config as Record, log.deprecated); + } + return confirm(config); + }; + static success = (config: ShowConfig) => success(config); + static error = (config: ShowConfig) => error(config); + static notice = (config: ShowConfig) => notice(config); + static warning = (config: ShowConfig) => warning(config); + static help = (config: ShowConfig) => help(config); + + render() { + const { v2, ...others } = this.props; + if (v2) { + return ; + } else { + return ; + } + } +} + +export default ConfigProvider.config(Dialog, { + transform: (props, deprecated) => { + return processProps(props, deprecated); + }, +}); diff --git a/components/dialog/inner.jsx b/components/dialog/inner.jsx deleted file mode 100644 index ba7d267bb3..0000000000 --- a/components/dialog/inner.jsx +++ /dev/null @@ -1,222 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import Button from '../button'; -import Icon from '../icon'; -import zhCN from '../locale/zh-cn'; -import { func, obj, guid, dom } from '../util'; - -const { makeChain } = func; -const { pickOthers } = obj; -const noop = () => {}; - -export default class Inner extends Component { - static propTypes = { - prefix: PropTypes.string, - className: PropTypes.string, - title: PropTypes.node, - children: PropTypes.node, - footer: PropTypes.oneOfType([PropTypes.bool, PropTypes.node]), - footerAlign: PropTypes.oneOf(['left', 'center', 'right']), - footerActions: PropTypes.array, - onOk: PropTypes.func, - onCancel: PropTypes.func, - okProps: PropTypes.object, - cancelProps: PropTypes.object, - closeable: PropTypes.bool, - onClose: PropTypes.func, - locale: PropTypes.object, - role: PropTypes.string, - rtl: PropTypes.bool, - width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - // set value for a fixed height dialog. Passing a value will absolutely position the footer to the bottom. - height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - maxHeight: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - v2: PropTypes.bool, - closeIcon: PropTypes.node, - pure: PropTypes.bool, - noPadding: PropTypes.bool, - }; - - static defaultProps = { - prefix: 'next-', - footerAlign: 'right', - footerActions: ['ok', 'cancel'], - onOk: noop, - onCancel: noop, - okProps: {}, - cancelProps: {}, - closeable: true, - onClose: noop, - locale: zhCN.Dialog, - role: 'dialog', - }; - - componentDidUpdate() { - // style 作为第一优先级 - const { - height: pheight, - style: { maxHeight, height: sheight = maxHeight || pheight }, - v2, - } = this.props; - if (this.bodyNode && v2 && sheight && sheight !== 'auto') { - const style = {}; - let headerHeight = 0, - footerHeight = 0; - if (this.headerNode) { - headerHeight = this.headerNode.getBoundingClientRect().height; - } - if (this.footerNode) { - footerHeight = this.footerNode.getBoundingClientRect().height; - } - const minHeight = headerHeight + footerHeight; - - let height = sheight; - if (sheight && typeof sheight === 'string') { - if (height.match(/calc|vh/)) { - style.maxHeight = `calc(${sheight} - ${minHeight}px)`; - style.overflowY = 'auto'; - } else { - height = parseInt(sheight); - } - } - - if (typeof height === 'number' && height > minHeight) { - style.maxHeight = height - minHeight; - style.overflowY = 'auto'; - } - - dom.setStyle(this.bodyNode, style); - } - } - - getNode(name, ref) { - this[name] = ref; - } - - renderHeader() { - const { prefix, title } = this.props; - if (title) { - this.titleId = guid('dialog-title-'); - return ( -
    - {title} -
    - ); - } - return null; - } - - renderBody() { - const { prefix, children, footer, noPadding } = this.props; - if (children) { - return ( -
    - {children} -
    - ); - } - return null; - } - - renderFooter() { - const { prefix, footer, footerAlign, footerActions, locale, height } = this.props; - - if (footer === false) { - return null; - } - - const newClassName = cx({ - [`${prefix}dialog-footer`]: true, - [`${prefix}align-${footerAlign}`]: true, - [`${prefix}dialog-footer-fixed-height`]: !!height, - }); - const footerContent = - footer === true || !footer - ? footerActions.map(action => { - const btnProps = this.props[`${action}Props`]; - const newBtnProps = { - ...btnProps, - prefix, - className: cx(`${prefix}dialog-btn`, btnProps.className), - onClick: makeChain( - this.props[`on${action[0].toUpperCase() + action.slice(1)}`], - btnProps.onClick - ), - children: btnProps.children || locale[action], - }; - if (action === 'ok') { - newBtnProps.type = 'primary'; - } - - return @@ -23,7 +23,7 @@ const Demo = () => { > Start your business here by searching a popular product - diff --git a/components/drawer/__docs__/index.en-us.md b/components/drawer/__docs__/index.en-us.md index f1e6b39ca2..742f09df61 100644 --- a/components/drawer/__docs__/index.en-us.md +++ b/components/drawer/__docs__/index.en-us.md @@ -19,47 +19,79 @@ version 1.25 add api `v2` to support open new version Dialog, feature as list: feature: -- use css (not js) to compute position, will easier +- use css (not js) to compute position, will easier - support `width/height` to fix width/height of drawer, or you can set `auto` to follow content width/height ## API ### Drawer -> Inherited Overlay.Popup's API unless otherwise specified - -| Param | Descripiton | Type | Default Value | -| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------- | --------------------------------------------------------------------------------- | -| trigger | trigger the overlay to show or hide elements | ReactElement | - | -| triggerType | trigger the overlay to show or hide operations, either 'click', 'hover', 'focus', or an array of them, such as ['hover', 'focus'] | String/Array | 'hover' | -| visible | whether the overlay is visiible currently | Boolean | - | -| animation | configure animation, support the {in: 'enter-class', out: 'leave-class' } object parameters, if set to false, do not play the animation. Refer to `Animate` component documentation for available animations. | Object/Boolean | { in: 'expandInDown', out: 'expandOutUp' } | -| hasMask | whether to show the mask | Boolean | false | -| closeable | [deprecated]controls how the dialog is closed. The value can be either a String or Boolean, where the string consists of the following values:
    **close** clicking the close button can close the dialog
    **mask** clicking the mask can close the dialog
    **esc** pressing the esc key can close the dialog
    such as 'close' or 'close,esc,mask'
    If set to true, all of the above close methods take effect
    If set to false, all of the above close methods will fail | String/Boolean | 'esc,close' | -| closeMode | [recommand]controls how the dialog is closed. The value can be either a String or Array:
    **close** clicking the close button can close the dialog
    **mask** clicking the mask can close the dialog
    **esc** pressing the esc key can close the dialog
    for example 'close' or ['close','esc','mask'] | Array<Enum>/Enum | - | -| onVisibleChange | callback function triggered when the ovlery is visible or hidden

    **signatures**:
    Function(visible: Boolean, type: String, e: Object) => void
    **params**:
    _visible_: {Boolean} whether the overlay is visible
    _type_: {String} the reason that triggers the overlay to show or hide
    _e_: {Object} DOM event | Function | func.noop | -| placement | placement of the drawer

    **options**:
    'top', 'right', 'bottom', 'left' | Enum | 'right' | -| v2 | v2 version | Boolean | - | | -| afterClose | [v2] callback after Drawer close

    **signatures**:
    Function() => void | Function | - | | - +| Param | Description | Type | Default Value | Required | Supported Version | +| --------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | -------- | ----------------- | +| closeable | [Deprecated] Control the way the drawer is closed | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | true | | - | +| closeMode | Control the way the dialog is closed | CloseMode \| CloseMode[] | - | | 1.21 | +| cache | Whether to retain the child node when hiding | boolean | - | | - | +| title | Title | React.ReactNode | - | | - | +| bodyStyle | Style on body | React.CSSProperties | - | | - | +| headerStyle | Style on header | React.CSSProperties | - | | - | +| animation | Animation playback method when showing and hiding

    **signature**:
    **params**:
    _animation_: animation | { in: string; out: string } \| false | \{ in: 'expandInDown', out: 'expandOutUp' \} | | - | +| visible | Whether to show | boolean | - | | - | +| width | Width, only effective when placement is left right | number \| string | - | | - | +| height | Height, only effective when placement is the top bottom | number \| string | - | | - | +| onClose | Callback when the dialog is closed | (reason: string, e: React.MouseEvent \| KeyboardEvent) => void | `() => {}` | | - | +| placement | The position of the page | 'top' \| 'right' \| 'bottom' \| 'left' | 'right' | | - | +| v2 | Enable v2 version | false \| undefined | false | | - | +| content | Content | React.ReactNode | - | | - | +| popupContainer | Render component container | string \| HTMLElement \| null | - | | - | +| hasMask | Whether there is a mask | boolean | true | | - | +| afterOpen | Callback after the dialog is opened | () => void | - | | - | +| onVisibleChange | [v2 Deprecated] Controlled mode (without trigger), only triggered when closed, equivalent to onClose | (visible: boolean, reason: string, e?: React.MouseEvent) => void | - | | - | + +### Drawer V2 + +| Param | Description | Type | Default Value | Required | Supported Version | +| -------------- | -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | -------- | ----------------- | +| closeable | [Deprecated] Control the way the drawer is closed | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | true | | - | +| closeMode | Control the way the dialog is closed | CloseMode \| CloseMode[] | - | | 1.21 | +| cache | Whether to retain the child node when hiding | boolean | - | | - | +| title | Title | React.ReactNode | - | | - | +| bodyStyle | Style on body | React.CSSProperties | - | | - | +| headerStyle | Style on header | React.CSSProperties | - | | - | +| animation | Animation playback method when showing and hiding

    **signature**:
    **params**:
    _animation_: animation | { in: string; out: string } \| false | \{ in: 'expandInDown', out: 'expandOutUp' \} | | - | +| visible | Whether to show | boolean | - | | - | +| width | Width, only effective when placement is left right | number \| string | - | | - | +| height | Height, only effective when placement is the top bottom | number \| string | - | | - | +| afterClose | Callback after the dialog is closed | () => void | - | | - | +| onClose | Callback when the dialog is closed | (reason: string, e: React.MouseEvent \| KeyboardEvent) => void | `() => {}` | | - | +| placement | The position of the page | 'top' \| 'right' \| 'bottom' \| 'left' | 'right' | | - | +| v2 | Enable v2 version | true | false | | - | +| content | Content | React.ReactNode | - | | - | +| popupContainer | Render component container | string \| HTMLElement \| null | - | | - | +| hasMask | Whether there is a mask | boolean | true | | - | + +### CloseMode + +```typescript +export type CloseMode = 'close' | 'mask' | 'esc'; +``` + ### Drawer.show The following only list common properties that config can pass, and other properties of the Dialog can also be passed in. -| Param | Descripiton | Type | Default Value | -| :------- | :-------------- | :-------- | :------- | -| title | title of drawer | ReactNode | '' | -| content | content of drawer | ReactNode | '' | +| Param | Descripiton | Type | Default Value | +| :------ | :---------------- | :-------- | :------------ | +| title | title of drawer | ReactNode | '' | +| content | content of drawer | ReactNode | '' | - ## ARIA and Keyboard -| Keyboard | Descripiton | -| :-------- | :--------------------------------------- | -| esc | pressing ESC will close dialog | -| tab | focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | -| shift+tab | back focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | +| Keyboard | Descripiton | +| :-------- | :---------------------------------------------------------------------------------------------------------- | +| esc | pressing ESC will close dialog | +| tab | focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | +| shift+tab | back focus on any element that can be focused, the focus remains in the dialog when the dialog is displayed | diff --git a/components/drawer/__docs__/index.md b/components/drawer/__docs__/index.md index d48e069378..cb78c017ee 100644 --- a/components/drawer/__docs__/index.md +++ b/components/drawer/__docs__/index.md @@ -27,26 +27,54 @@ ### Drawer -> 继承 Overlay.Popup 的 API,除非特别说明 - -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- | ------------------------------------------ | ---- | -| width | 宽度,仅在 placement是 left right 的时候生效 | Number/String | - | | -| height | 高度,仅在 placement是 top bottom 的时候生效 | Number/String | - | | -| closeable | [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成:
    **close** 表示点击关闭按钮可以关闭对话框
    **mask** 表示点击遮罩区域可以关闭对话框
    **esc** 表示按下 esc 键可以关闭对话框
    如 'close' 或 'close,esc,mask'
    如果设置为 true,则以上关闭方式全部生效
    如果设置为 false,则以上关闭方式全部失效 | String/Boolean | true | | -| cache | 隐藏时是否保留子节点,不销毁 | Boolean | - | | -| closeMode | [推荐]控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举:
    **close** 表示点击关闭按钮可以关闭对话框
    **mask** 表示点击遮罩区域可以关闭对话框
    **esc** 表示按下 esc 键可以关闭对话框
    如 'close' 或 ['close','esc','mask'], \[] | Array<Enum>/Enum | - | 1.21 | -| onClose | 对话框关闭时触发的回调函数

    **签名**:
    Function(trigger: String, event: Object) => void
    **参数**:
    _trigger_: {String} 关闭触发行为的描述字符串
    _event_: {Object} 关闭时事件对象 | Function | () => {} | | -| afterOpen | [v2废弃]对话框打开后的回调函数

    **签名**:
    Function() => void | Function | - | | -| placement | 位于页面的位置

    **可选值**:
    'top', 'right', 'bottom', 'left' | Enum | 'right' | | -| title | 标题 | ReactNode | - | | -| headerStyle | header上的样式 | Object | - | | -| bodyStyle | body上的样式 | Object | - | | -| visible | 是否显示 | Boolean | - | | -| hasMask | 是否显示遮罩 | Boolean | true | | -| animation | 显示隐藏时动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画。 请参考 Animate 组件的文档获取可用的动画名 | Object/Boolean | { in: 'expandInDown', out: 'expandOutUp' } | | -| v2 | 开启 v2 | Boolean | - | | -| afterClose | [v2] 弹窗关闭后的回调

    **签名**:
    Function() => void | Function | - | | +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| --------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | -------- | -------- | +| closeable | [废弃] 同 closeMode, 控制对话框关闭的方式, | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | true | | - | +| closeMode | [推荐] 控制对话框关闭的方式 | CloseMode \| CloseMode[] | - | | 1.21 | +| cache | 隐藏时是否保留子节点,不销毁 | boolean | - | | - | +| title | 标题 | React.ReactNode | - | | - | +| bodyStyle | body 上的样式 | React.CSSProperties | - | | - | +| headerStyle | header 上的样式 | React.CSSProperties | - | | - | +| animation | 显示隐藏时动画的播放方式

    **签名**:
    **参数**:
    _animation_: 指定进场和出场动画的对象。 | { in: string; out: string } \| false | \{ in: 'expandInDown', out: 'expandOutUp' \} | | - | +| visible | 是否显示 | boolean | - | | - | +| width | 宽度,仅在 placement 是 left right 的时候生效 | number \| string | - | | - | +| height | 高度,仅在 placement 是 top bottom 的时候生效 | number \| string | - | | - | +| onClose | 对话框关闭时触发的回调函数 | (reason: string, e: React.MouseEvent \| KeyboardEvent) => void | `() => {}` | | - | +| placement | 位于页面的位置 | 'top' \| 'right' \| 'bottom' \| 'left' | 'right' | | - | +| v2 | 开启 v2 | false \| undefined | false | | - | +| content | 内容 | React.ReactNode | - | | - | +| popupContainer | 渲染组件的容器 | string \| HTMLElement \| null | - | | - | +| hasMask | 是否显示遮罩 | boolean | true | | - | +| afterOpen | [v2 废弃] 对话框打开后的回调函数 | () => void | - | | - | +| onVisibleChange | [v2 废弃] 受控模式下 (没有 trigger 的时候),只会在关闭时触发,相当于 onClose | (visible: boolean, reason: string, e?: React.MouseEvent) => void | - | | - | + +### Drawer V2 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | 支持版本 | +| -------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------- | -------------------------------------------- | -------- | -------- | +| closeable | [废弃] 同 closeMode, 控制对话框关闭的方式, | 'close' \| 'mask' \| 'esc' \| boolean \| 'close,mask' \| 'close,esc' \| 'mask,esc' | true | | - | +| closeMode | [推荐] 控制对话框关闭的方式 | CloseMode \| CloseMode[] | - | | 1.21 | +| cache | 隐藏时是否保留子节点,不销毁 | boolean | - | | - | +| title | 标题 | React.ReactNode | - | | - | +| bodyStyle | body 上的样式 | React.CSSProperties | - | | - | +| headerStyle | header 上的样式 | React.CSSProperties | - | | - | +| animation | 显示隐藏时动画的播放方式

    **签名**:
    **参数**:
    _animation_: 指定进场和出场动画的对象。 | { in: string; out: string } \| false | \{ in: 'expandInDown', out: 'expandOutUp' \} | | - | +| visible | 是否显示 | boolean | - | | - | +| width | 宽度,仅在 placement 是 left right 的时候生效 | number \| string | - | | - | +| height | 高度,仅在 placement 是 top bottom 的时候生效 | number \| string | - | | - | +| afterClose | [v2] 弹窗关闭后的回调 | () => void | - | | - | +| onClose | 对话框关闭时触发的回调函数 | (reason: string, e: React.MouseEvent \| KeyboardEvent) => void | `() => {}` | | - | +| placement | 位于页面的位置 | 'top' \| 'right' \| 'bottom' \| 'left' | 'right' | | - | +| v2 | 开启 v2 | true | false | | - | +| content | 内容 | React.ReactNode | - | | - | +| popupContainer | 渲染组件的容器 | string \| HTMLElement \| null | - | | - | +| hasMask | 是否显示遮罩 | boolean | true | | - | + +### CloseMode + +```typescript +export type CloseMode = 'close' | 'mask' | 'esc'; +``` @@ -54,10 +82,10 @@ 以下只列举 config 可以传入的常用属性,Drawer 组件其他属性也可以传入 -| 属性 | 说明 | 类型 | 默认值 | -| :------ | :-- | :-------- | :-- | -| title | 标题 | ReactNode | '' | -| content | 内容 | ReactNode | '' | +| 属性 | 说明 | 类型 | 默认值 | +| :------ | :--- | :-------- | :----- | +| title | 标题 | ReactNode | '' | +| content | 内容 | ReactNode | '' | ### Drawer.withContext @@ -70,8 +98,8 @@ ## 无障碍键盘操作指南 -| 键盘 | 说明 | -| :-------- | :--------------------------------------- | -| esc | 按下ESC键将会关闭dialog而不触发任何的动作 | +| 键盘 | 说明 | +| :-------- | :------------------------------------------------------------------------ | +| esc | 按下ESC键将会关闭dialog而不触发任何的动作 | | tab | 正向聚焦到任何可以被聚焦的元素, 在Dialog显示的时候,焦点始终保持在框体内 | | shift+tab | 反向聚焦到任何可以被聚焦的元素,在Dialog显示的时候,焦点始终保持在框体内 | diff --git a/components/drawer/__docs__/theme/index.jsx b/components/drawer/__docs__/theme/index.jsx deleted file mode 100644 index 2f78827238..0000000000 --- a/components/drawer/__docs__/theme/index.jsx +++ /dev/null @@ -1,129 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import '../../../demo-helper/style'; -import '../../style'; -import { Demo, DemoGroup, initDemo } from '../../../demo-helper'; -import Drawer from '../../index'; - -import zhCN from '../../../locale/zh-cn'; -import enUS from '../../../locale/en-us'; - -const i18nMaps = { - 'en-us': { - title: 'Title Here', - content: ':) Start your business here by searching a popular product', - }, - - 'zh-cn': { - title: '这里是标题', - content: '开启您的贸易生活从 Alibaba.com 开始', - } -}; - -class FunctionDemo extends Component { - state = { - demoFunction: { - hasTitle: { - label: '标题', - value: 'true', - enum: [{ - label: '显示', - value: 'true' - }, { - label: '隐藏', - value: 'false' - }] - }, - hasCloseIcon: { - label: '有无关闭按钮', - value: 'true', - enum: [{ - label: '有', - value: 'true' - }, { - label: '无', - value: 'false' - }] - }, - placement: { - label: '方向', - value: 'right', - enum: [{ - label: '上', - value: 'top' - }, { - label: '下', - value: 'bottom' - }, { - label: '左', - value: 'left' - }, { - label: '右', - value: 'right' - }] - }, - } - } - onFunctionChange = demoFunction => { - this.setState({ - demoFunction - }); - } - - renderMask(hasMask, content) { - return hasMask ? ( -
    -
    - {content} -
    - ) : content; - } - - render() { - // eslint-disable-next-line - const { lang, i18n } = this.props; - const locale = (lang === 'en-us' ? enUS : zhCN).Drawer; - const hasTitle = this.state.demoFunction.hasTitle.value === 'true'; - const hasCloseIcon = this.state.demoFunction.hasCloseIcon.value === 'true'; - - const placement = this.state.demoFunction.placement.value; - const style = { - position: 'absolute', - top: placement === 'bottom' ? 'auto' : 0, - [placement]: 0, - }; - - const normalContent = ( - - {i18n.content} - - ); - - return ( -
    - - - - {this.renderMask(true, normalContent)} - - - -
    - ); - } -} - - -const render = (lang = 'en-us') => { - const i18n = i18nMaps[lang]; - ReactDOM.render(, document.getElementById('container')); -}; - -window.renderDemo = render; -window.renderDemo('en-us'); -initDemo('drawer'); diff --git a/components/drawer/__docs__/theme/index.tsx b/components/drawer/__docs__/theme/index.tsx new file mode 100644 index 0000000000..c5769e31d2 --- /dev/null +++ b/components/drawer/__docs__/theme/index.tsx @@ -0,0 +1,166 @@ +import React, { Component } from 'react'; +import ReactDOM from 'react-dom'; +import '../../../demo-helper/style'; +import '../../style'; +import { + Demo, + DemoGroup, + initDemo, + type DemoFunctionDefineForArray, + type DemoFunctionDefineForObject, +} from '../../../demo-helper'; +import Drawer from '../../index'; + +import zhCN from '../../../locale/zh-cn'; +import enUS from '../../../locale/en-us'; + +interface FunctionProps { + lang: string; + i18n: { + title: string; + content: string; + }; +} + +const i18nMaps = { + 'en-us': { + title: 'Title Here', + content: ':) Start your business here by searching a popular product', + }, + + 'zh-cn': { + title: '这里是标题', + content: '开启您的贸易生活从 Alibaba.com 开始', + }, +}; + +class FunctionDemo extends Component { + state = { + demoFunction: { + hasTitle: { + label: '标题', + value: 'true', + enum: [ + { + label: '显示', + value: 'true', + }, + { + label: '隐藏', + value: 'false', + }, + ], + }, + hasCloseIcon: { + label: '有无关闭按钮', + value: 'true', + enum: [ + { + label: '有', + value: 'true', + }, + { + label: '无', + value: 'false', + }, + ], + }, + placement: { + label: '方向', + value: 'right', + enum: [ + { + label: '上', + value: 'top', + }, + { + label: '下', + value: 'bottom', + }, + { + label: '左', + value: 'left', + }, + { + label: '右', + value: 'right', + }, + ], + }, + }, + }; + onFunctionChange = ( + demoFunction: Record | DemoFunctionDefineForArray[] + ) => { + this.setState({ + demoFunction, + }); + }; + + renderMask(hasMask: boolean, content: object | null | undefined) { + return hasMask ? ( +
    +
    + {content} +
    + ) : ( + content + ); + } + + render() { + const { lang, i18n } = this.props; + const locale = (lang === 'en-us' ? enUS : zhCN).Drawer; + const hasTitle = this.state.demoFunction.hasTitle.value === 'true'; + const hasCloseIcon = this.state.demoFunction.hasCloseIcon.value === 'true'; + + const placement = this.state.demoFunction.placement.value as + | 'top' + | 'bottom' + | 'left' + | 'right'; + const style: React.CSSProperties = { + position: 'absolute', + top: placement === 'bottom' ? 'auto' : 0, + [placement]: 0, + }; + + const normalContent = ( + + {i18n.content} + + ); + + return ( +
    + + + {this.renderMask(true, normalContent)} + + +
    + ); + } +} + +const render = (lang = 'en-us') => { + const i18n = i18nMaps[lang as keyof typeof i18nMaps]; + ReactDOM.render(, document.getElementById('container')); +}; + +window.renderDemo = render; +window.renderDemo('en-us'); +initDemo('drawer'); diff --git a/components/drawer/__tests__/a11y-spec.js b/components/drawer/__tests__/a11y-spec.js deleted file mode 100644 index 326198fd3b..0000000000 --- a/components/drawer/__tests__/a11y-spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import React from 'react'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Drawer from '../index'; -import '../style'; -import { test, unmount } from '../../util/__tests__/legacy/a11y/validate'; -import { roleType, isHeading, isButton } from '../../util/__tests__/legacy/a11y/checks'; - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ - -Enzyme.configure({ adapter: new Adapter() }); - -describe('Drawer A11y', () => { - describe('Basic', () => { - let wrapper; - - afterEach(() => { - if (wrapper && wrapper.unmount) { - wrapper.unmount(); - wrapper = null; - } - unmount(); - }); - - it('should not have any violations', async () => { - wrapper = await mount(); - return test('.next-overlay-wrapper'); - }); - }); -}); diff --git a/components/drawer/__tests__/a11y-spec.tsx b/components/drawer/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..a326191352 --- /dev/null +++ b/components/drawer/__tests__/a11y-spec.tsx @@ -0,0 +1,12 @@ +import React from 'react'; +import Drawer from '../index'; +import { testReact } from '../../util/__tests__/a11y/validate'; +import '../style'; + +describe('Drawer A11y', () => { + describe('Basic', () => { + it('should not have any violations', async () => { + await testReact(); + }); + }); +}); diff --git a/components/drawer/__tests__/index-spec.js b/components/drawer/__tests__/index-spec.js deleted file mode 100644 index 728b781649..0000000000 --- a/components/drawer/__tests__/index-spec.js +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import assert from 'power-assert'; -import Enzyme, { shallow } from 'enzyme'; -import ReactTestUtils from 'react-dom/test-utils'; -import Adapter from 'enzyme-adapter-react-16'; -import { dom } from '../../util'; -import Drawer from '../index'; -import ConfigProvider from '../../config-provider'; -import '../style'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ -/* global describe it beforeEach */ -const { hasClass, getStyle } = dom; - -const render = element => { - let inc; - const container = document.createElement('div'); - document.body.appendChild(container); - ReactDOM.render(element, container, function() { - inc = this; - }); - return { - setProps: props => { - const clonedElement = React.cloneElement(element, props); - ReactDOM.render(clonedElement, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - }, - instance: () => { - return inc; - }, - find: selector => { - return container.querySelectorAll(selector); - }, - }; -}; - -class DrawerDemo extends React.Component { - state = { - visible: false, - }; - - onOpen = () => { - this.setState({ - visible: true, - }); - }; - - onClose = () => { - this.setState({ - visible: false, - }); - }; - - render() { - return ( -
    - - - 开启您的贸易生活从 Alibaba.com 开始 - -
    - ); - } -} - -describe('Drawer', () => { - let wrapper; - - beforeEach(() => { - const overlay = document.querySelectorAll('.next-overlay-wrapper'); - overlay.forEach(dom => { - document.body.removeChild(dom); - }); - }); - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should show and hide', () => { - wrapper = render(); - const btn = document.getElementById('open-drawer'); - ReactTestUtils.Simulate.click(btn); - - assert(document.querySelector('.next-drawer')); - const closeLink = document.querySelector('.next-drawer-close'); - ReactTestUtils.Simulate.click(closeLink); - - assert(!document.querySelector('.next-drawer')); - }); - - it('should support placement', () => { - ['top', 'left', 'bottom', 'right'].forEach(dir => { - wrapper && wrapper.unmount(); - - wrapper = render(); - assert(hasClass(document.querySelector('.next-drawer'), `next-drawer-${dir}`)); - }); - }); - - it('should work when set ', () => { - wrapper = render( - -
    - - Start your business here by searching a popular product - -
    -
    - ); - - const overlay = document.querySelector('#dialog-popupcontainer > .next-overlay-wrapper'); - assert(overlay); - }); - - it('should hide close link if set closeable to false', () => { - wrapper = render(); - assert(!document.querySelector('.next-drawer-close')); - }); - - it('should support headerStyle/bodyStyle', () => { - wrapper = render( - - body - - ); - - assert(getStyle(document.querySelector('.next-drawer-header'), 'background'), 'blue'); - assert(getStyle(document.querySelector('.next-drawer-body'), 'background'), 'red'); - }); -}); diff --git a/components/drawer/__tests__/index-spec.tsx b/components/drawer/__tests__/index-spec.tsx new file mode 100644 index 0000000000..1d1d93d903 --- /dev/null +++ b/components/drawer/__tests__/index-spec.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import Drawer from '../index'; +import ConfigProvider from '../../config-provider'; +import '../style'; +import type { DrawerProps } from '../types'; + +describe('Drawer', () => { + it('should show and hide', () => { + class DrawerDemo extends React.Component<{ animation: DrawerProps['animation'] }> { + state = { + visible: false, + }; + + onOpen = () => { + this.setState({ + visible: true, + }); + }; + + onClose = () => { + this.setState({ + visible: false, + }); + }; + + render() { + return ( +
    + + + 开启您的贸易生活从 Alibaba.com 开始 + +
    + ); + } + } + cy.mount(); + cy.get('button#open-drawer').click(); + cy.get('.next-drawer').should('be.visible'); + cy.get('.next-drawer-close').click(); + cy.get('.next-drawer').should('not.exist'); + }); + + it('should support placement', () => { + ['top', 'left', 'bottom', 'right'].forEach((dir: 'top' | 'left' | 'bottom' | 'right') => { + cy.mount(); + cy.get('.next-drawer').should('exist'); + cy.get(`.next-drawer-${dir}`).should('exist'); + }); + }); + + it('should work when set ', () => { + cy.mount( + +
    + + Start your business here by searching a popular product + +
    +
    + ); + cy.get('#dialog-popupcontainer').within(() => { + cy.get('.next-overlay-wrapper').should('exist'); + }); + }); + + it('should hide close link if set closeable to false', () => { + cy.mount(); + cy.get('.next-drawer-close').should('not.exist'); + }); + + it('should hide close link if set closeMode to []', () => { + cy.mount(); + cy.get('.next-drawer-close').should('not.exist'); + }); + + it('should support headerStyle/bodyStyle', () => { + cy.mount( + + body + + ); + cy.get('.next-drawer-header').should('have.css', 'background-color', 'rgb(0, 0, 255)'); + cy.get('.next-drawer-body').should('have.css', 'background-color', 'rgb(255, 0, 0)'); + }); +}); diff --git a/components/drawer/__tests__/index-v2-spec.js b/components/drawer/__tests__/index-v2-spec.js deleted file mode 100644 index 94915535ae..0000000000 --- a/components/drawer/__tests__/index-v2-spec.js +++ /dev/null @@ -1,167 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import assert from 'power-assert'; -import Enzyme, { shallow } from 'enzyme'; -import ReactTestUtils from 'react-dom/test-utils'; -import Adapter from 'enzyme-adapter-react-16'; -import { dom } from '../../util'; -import Drawer from '../index'; -import ConfigProvider from '../../config-provider'; -import '../style'; - -Enzyme.configure({ adapter: new Adapter() }); - -/* eslint-disable react/jsx-filename-extension */ -/* global describe it afterEach */ -/* global describe it beforeEach */ -const { hasClass, getStyle } = dom; - -const render = element => { - let inc; - const container = document.createElement('div'); - document.body.appendChild(container); - ReactDOM.render(element, container, function() { - inc = this; - }); - return { - setProps: props => { - const clonedElement = React.cloneElement(element, props); - ReactDOM.render(clonedElement, container); - }, - unmount: () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - }, - instance: () => { - return inc; - }, - find: selector => { - return container.querySelectorAll(selector); - }, - }; -}; - -class DrawerDemo extends React.Component { - state = { - visible: false, - }; - - onOpen = () => { - this.setState({ - visible: true, - }); - }; - - onClose = () => { - this.setState({ - visible: false, - }); - }; - - render() { - return ( -
    - - - 开启您的贸易生活从 Alibaba.com 开始 - -
    - ); - } -} - -describe('Drawer v2', () => { - let wrapper; - const delay = time => new Promise(resolve => setTimeout(resolve, time)); - - beforeEach(() => { - const overlay = document.querySelectorAll('.next-overlay-wrapper'); - overlay.forEach(dom => { - document.body.removeChild(dom); - }); - }); - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - }); - - it('should show and hide', async () => { - wrapper = render(); - const btn = document.getElementById('open-drawer'); - ReactTestUtils.Simulate.click(btn); - await delay(20); - assert(document.querySelector('.next-drawer')); - const closeLink = document.querySelector('.next-drawer-close'); - ReactTestUtils.Simulate.click(closeLink); - await delay(20); - - assert(!document.querySelector('.next-drawer')); - }); - - it('should support placement', () => { - ['top', 'left', 'bottom', 'right'].forEach(dir => { - wrapper && wrapper.unmount(); - - wrapper = render(); - assert(hasClass(document.querySelector('.next-drawer-wrapper'), `next-drawer-${dir}`)); - }); - }); - - it('should work when set ', async () => { - wrapper = render( - -
    - - Start your business here by searching a popular product - -
    -
    - ); - - await delay(20); - assert(document.querySelector('#dialog-popupcontainer > .next-overlay-wrapper')); - }); - - it('should support headerStyle/bodyStyle', () => { - wrapper = render( - - body - - ); - - assert(getStyle(document.querySelector('.next-drawer-header'), 'background'), 'blue'); - assert(getStyle(document.querySelector('.next-drawer-body'), 'background'), 'red'); - }); - - it('quick-calling should should support set prefix for dialog', () => { - const { hide } = Drawer.show({ - v2: true, - prefix: 'test-', - title: 'Title', - content: , - }); - - assert(hasClass(document.querySelector('.test-drawer'), 'test-closeable')); - assert(document.querySelector('.drawer-quick-content')); - - hide(); - }); -}); diff --git a/components/drawer/__tests__/index-v2-spec.tsx b/components/drawer/__tests__/index-v2-spec.tsx new file mode 100644 index 0000000000..d3205f255a --- /dev/null +++ b/components/drawer/__tests__/index-v2-spec.tsx @@ -0,0 +1,108 @@ +import React from 'react'; +import Drawer from '../index'; +import ConfigProvider from '../../config-provider'; +import '../style'; +import type { DrawerProps } from '../types'; + +describe('Drawer v2', () => { + it('should show and hide', () => { + class DrawerDemo extends React.Component<{ animation: DrawerProps['animation'] }> { + state = { + visible: false, + }; + + onOpen = () => { + this.setState({ + visible: true, + }); + }; + + onClose = () => { + this.setState({ + visible: false, + }); + }; + + render() { + return ( +
    + + + 开启您的贸易生活从 Alibaba.com 开始 + +
    + ); + } + } + cy.mount(); + cy.get('button#open-drawer').click(); + cy.get('.next-drawer').should('be.visible'); + cy.get('.next-drawer-close').click(); + cy.get('.next-drawer').should('not.exist'); + }); + + it('should support placement', () => { + ['top', 'left', 'bottom', 'right'].forEach((dir: 'top' | 'left' | 'bottom' | 'right') => { + cy.mount(); + cy.get('.next-drawer').should('exist'); + cy.get(`.next-drawer-${dir}`).should('exist'); + }); + }); + + it('should work when set ', () => { + cy.mount( + +
    + + Start your business here by searching a popular product + +
    +
    + ); + cy.get('#dialog-popupcontainer').within(() => { + cy.get('.next-overlay-wrapper').should('exist'); + }); + }); + + it('should support headerStyle/bodyStyle', () => { + cy.mount( + + body + + ); + cy.get('.next-drawer-header').should('have.css', 'background-color', 'rgb(0, 0, 255)'); + cy.get('.next-drawer-body').should('have.css', 'background-color', 'rgb(255, 0, 0)'); + }); + + it('quick-calling should should support set prefix for dialog', () => { + const { hide } = Drawer.show({ + v2: true, + prefix: 'test-', + title: 'Title', + content: , + }); + + cy.get('.test-drawer').should('exist'); + cy.get('.test-closeable').should('exist'); + cy.get('.drawer-quick-content').should('exist'); + + cy.then(() => { + hide(); + }); + }); +}); diff --git a/components/drawer/drawer-v2.jsx b/components/drawer/drawer-v2.jsx deleted file mode 100644 index 5a3a5e1e62..0000000000 --- a/components/drawer/drawer-v2.jsx +++ /dev/null @@ -1,334 +0,0 @@ -/* istanbul ignore file */ -import React, { useState, useRef, useEffect, useContext } from 'react'; -import ReactDOM from 'react-dom'; -import classNames from 'classnames'; -import Overlay from '@alifd/overlay'; - -import Inner from './inner'; -import Animate from '../animate'; -import zhCN from '../locale/zh-cn'; -import { log, func, dom, focus, guid } from '../util'; -import scrollLocker from '../dialog/scroll-locker'; - -const { OverlayContext } = Overlay; -const noop = func.noop; - -const getAnimation = placement => { - let animation; - switch (placement) { - case 'top': - animation = { - in: 'slideInDown', - out: 'slideOutUp', - }; - break; - case 'bottom': - animation = { - in: 'slideInUp', - out: 'slideOutDown', - }; - break; - case 'left': - animation = { - in: 'slideInLeft', - out: 'slideOutLeft', - }; - break; - case 'right': - default: - animation = { - in: 'slideInRight', - out: 'slideOutRight', - }; - break; - } - - return animation; -}; - -const Drawer = props => { - if (!useState || !useRef || !useEffect) { - log.warning('need react version > 16.8.0'); - return null; - } - - const { - prefix = 'next-', - hasMask = true, - autoFocus = false, - className, - title, - children, - cache, - closeMode = ['close', 'mask', 'esc'], - width, - height, - onClose, - placement = 'right', - headerStyle, - bodyStyle, - visible: pvisible, - afterClose = noop, - locale = zhCN.Drawer, - rtl, - animation, - wrapperStyle, - popupContainer = document.body, - style, - ...others - } = props; - - const [firstVisible, setFirst] = useState(pvisible || false); - const [visible, setVisible] = useState(pvisible); - const getContainer = - typeof popupContainer === 'string' - ? () => document.getElementById(popupContainer) - : typeof popupContainer !== 'function' - ? () => popupContainer - : popupContainer; - const [container, setContainer] = useState(getContainer()); - const drawerRef = useRef(null); - const wrapperRef = useRef(null); - const lastFocus = useRef(null); - const locker = useRef(null); - const [uuid] = useState(guid()); - const { setVisibleOverlayToParent, ...otherContext } = useContext(OverlayContext); - const childIDMap = useRef(new Map()); - const isAnimationEnd = useRef(false); // 动效是否结束, 因为时机非常快用 state 太慢 - const [, forceUpdate] = useState(); - - // 动效结束,强制重新渲染 - const markAnimationEnd = state => { - isAnimationEnd.current = state; - forceUpdate({}); - }; - - let canCloseByEsc = false; - let canCloseByMask = false; - let closeable = false; - - const closeModeArray = Array.isArray(closeMode) ? closeMode : [closeMode]; - closeModeArray.forEach(mode => { - switch (mode) { - case 'esc': - canCloseByEsc = true; - break; - case 'mask': - canCloseByMask = true; - break; - case 'close': - closeable = true; - break; - } - }); - - // visible 受控 - useEffect(() => { - if ('visible' in props) { - setVisible(pvisible); - } - }, [pvisible]); - - // 打开遮罩后 document.body 滚动处理 - useEffect(() => { - if (visible && hasMask) { - const style = { - overflow: 'hidden', - }; - - if (dom.hasScroll(document.body)) { - const scrollWidth = dom.scrollbar().width; - if (scrollWidth) { - style.paddingRight = `${dom.getStyle(document.body, 'paddingRight') + dom.scrollbar().width}px`; - } - } - locker.current = scrollLocker.lock(document.body, style); - } - }, [visible && hasMask]); - - const handleClose = (targetType, e) => { - setVisibleOverlayToParent(uuid, null); - typeof onClose === 'function' && onClose(targetType, e); - }; - - const keydownEvent = e => { - if (e.keyCode === 27 && canCloseByEsc && !childIDMap.current.size) { - handleClose('esc', e); - } - }; - - // esc 键盘事件处理 - useEffect(() => { - if (visible && canCloseByEsc) { - document.body.addEventListener('keydown', keydownEvent, false); - return () => { - document.body.removeEventListener('keydown', keydownEvent, false); - }; - } - }, [visible && canCloseByEsc]); - - // 优化: 第一次加载并且 visible=false 的情况不挂载弹窗 - useEffect(() => { - !firstVisible && visible && setFirst(true); - }, [visible]); - - // container 异步加载情况 - useEffect(() => { - if (!container) { - setTimeout(() => { - setContainer(getContainer()); - }); - } - }, [container]); - - // Drawer 关闭时候的处理。1. 结束的时候不管动效是不是已经结束都要隐藏弹窗;2. 需要把focus态还原到触发节点 - const handleExited = () => { - if (!isAnimationEnd.current) { - markAnimationEnd(true); - dom.setStyle(wrapperRef.current, 'display', 'none'); - scrollLocker.unlock(document.body, locker.current); - - if (autoFocus && lastFocus.current) { - try { - lastFocus.current.focus(); - } finally { - // ignore ... - } - lastFocus.current = null; - } - afterClose(); - } - }; - - // visible? : null; 这种写法会触发卸载 - useEffect(() => { - return () => { - handleExited(); - }; - }, []); - - if (firstVisible === false || !container) { - return null; - } - - if (!visible && !cache && isAnimationEnd.current) { - return null; - } - - const handleMaskClick = e => { - if (!canCloseByMask) { - return; - } - - handleClose('maskClick', e); - }; - - const handleEnter = () => { - markAnimationEnd(false); - dom.setStyle(wrapperRef.current, 'display', ''); - }; - const handleEntered = () => { - if (autoFocus && drawerRef.current && drawerRef.current.bodyNode) { - const focusableNodes = focus.getFocusNodeList(drawerRef.current.bodyNode); - if (focusableNodes.length > 0 && focusableNodes[0]) { - lastFocus.current = document.activeElement; - focusableNodes[0].focus(); - } - } - setVisibleOverlayToParent(uuid, drawerRef.current); - }; - - const wrapperCls = classNames({ - [`${prefix}overlay-wrapper`]: true, - opened: visible, - }); - const innerWrapperCls = classNames({ - [`${prefix}overlay-inner`]: true, - [`${prefix}drawer-wrapper`]: true, - [`${prefix}drawer-${placement}`]: true, - [className]: !!className, - }); - const drawerCls = classNames({ - [`${prefix}drawer-v2`]: true, - [className]: !!className, - }); - - const newAnimation = - animation === null || animation === false ? null : animation ? animation : getAnimation(placement); - - const timeout = { - appear: 300, - enter: 300, - exit: 250, - }; - - const getVisibleOverlayFromChild = (id, node) => { - if (node) { - childIDMap.current.set(id, node); - } else { - childIDMap.current.delete(id); - } - // 让父级也感知 - setVisibleOverlayToParent(id, node); - }; - - const nstyle = { - width, - height, - ...style, - }; - - return ( - - {ReactDOM.createPortal( -
    - {hasMask ? ( - -
    - - ) : null} - -
    - - handleClose('closeClick', ...args)} - > - {children} - - -
    -
    , - container - )} - - ); -}; - -export default Drawer; diff --git a/components/drawer/drawer-v2.tsx b/components/drawer/drawer-v2.tsx new file mode 100644 index 0000000000..17e5ab2cdb --- /dev/null +++ b/components/drawer/drawer-v2.tsx @@ -0,0 +1,355 @@ +/* istanbul ignore file */ +import React, { useState, useRef, useEffect, useContext } from 'react'; +import ReactDOM from 'react-dom'; +import classNames from 'classnames'; +import Overlay from '@alifd/overlay'; + +import Inner from './inner'; +import Animate from '../animate'; +import zhCN from '../locale/zh-cn'; +import { log, func, dom, focus, guid } from '../util'; +import scrollLocker from '../dialog/scroll-locker'; +import type { DrawerV2Props } from './types'; + +const { OverlayContext } = Overlay; +const noop = func.noop; + +interface CustomDrawerElement extends HTMLDivElement { + bodyNode?: HTMLElement; +} + +const getAnimation = (placement: string) => { + let animation; + switch (placement) { + case 'top': + animation = { + in: 'slideInDown', + out: 'slideOutUp', + }; + break; + case 'bottom': + animation = { + in: 'slideInUp', + out: 'slideOutDown', + }; + break; + case 'left': + animation = { + in: 'slideInLeft', + out: 'slideOutLeft', + }; + break; + case 'right': + default: + animation = { + in: 'slideInRight', + out: 'slideOutRight', + }; + break; + } + + return animation; +}; + +const Drawer = (props: DrawerV2Props) => { + if (!useState || !useRef || !useEffect) { + log.warning('need react version > 16.8.0'); + return null; + } + + const { + prefix = 'next-', + hasMask = true, + autoFocus = false, + className, + title, + children, + cache, + closeMode = ['close', 'mask', 'esc'], + width, + height, + onClose, + placement = 'right', + headerStyle, + bodyStyle, + visible: pvisible, + afterClose = noop, + locale = zhCN.Drawer, + rtl, + animation, + wrapperStyle, + popupContainer = document.body, + style, + ...others + } = props; + + const [firstVisible, setFirst] = useState(pvisible || false); + const [visible, setVisible] = useState(pvisible); + const getContainer = + typeof popupContainer === 'string' + ? () => document.getElementById(popupContainer) + : typeof popupContainer !== 'function' + ? () => popupContainer + : popupContainer; + const [container, setContainer] = useState(getContainer()); + const drawerRef = useRef(null); + const wrapperRef = useRef(null); + const lastFocus = useRef(null); + const locker = useRef | null>(null); + const [uuid] = useState(guid()); + const { setVisibleOverlayToParent, ...otherContext } = useContext(OverlayContext); + const childIDMap = useRef(new Map()); + const isAnimationEnd = useRef(false); // 动效是否结束, 因为时机非常快用 state 太慢 + const [, forceUpdate] = useState(); + + // 动效结束,强制重新渲染 + const markAnimationEnd = (state: boolean) => { + isAnimationEnd.current = state; + forceUpdate({}); + }; + + let canCloseByEsc = false; + let canCloseByMask = false; + let closeable = false; + + const closeModeArray = Array.isArray(closeMode) ? closeMode : [closeMode]; + closeModeArray.forEach(mode => { + switch (mode) { + case 'esc': + canCloseByEsc = true; + break; + case 'mask': + canCloseByMask = true; + break; + case 'close': + closeable = true; + break; + } + }); + + // visible 受控 + useEffect(() => { + if ('visible' in props) { + setVisible(pvisible); + } + }, [pvisible]); + + // 打开遮罩后 document.body 滚动处理 + useEffect(() => { + if (visible && hasMask) { + const style: { paddingRight?: string; overflow: string } = { + overflow: 'hidden', + }; + + if (dom.hasScroll(document.body)) { + const scrollWidth = dom.scrollbar().width; + if (scrollWidth) { + style.paddingRight = `${ + dom.getStyle(document.body, 'paddingRight').toString() + + dom.scrollbar().width + }px`; + } + } + locker.current = scrollLocker.lock(document.body, style); + } + }, [visible && hasMask]); + + const handleClose = ( + targetType: string, + e: React.MouseEvent | KeyboardEvent + ) => { + setVisibleOverlayToParent(uuid, null); + typeof onClose === 'function' && onClose(targetType, e); + }; + + const keydownEvent = (e: KeyboardEvent) => { + if (e.keyCode === 27 && canCloseByEsc && !childIDMap.current.size) { + handleClose('esc', e); + } + }; + + // esc 键盘事件处理 + useEffect(() => { + if (visible && canCloseByEsc) { + document.body.addEventListener('keydown', keydownEvent, false); + return () => { + document.body.removeEventListener('keydown', keydownEvent, false); + }; + } + }, [visible && canCloseByEsc]); + + // 优化: 第一次加载并且 visible=false 的情况不挂载弹窗 + useEffect(() => { + !firstVisible && visible && setFirst(true); + }, [visible]); + + // container 异步加载情况 + useEffect(() => { + if (!container) { + setTimeout(() => { + setContainer(getContainer()); + }); + } + }, [container]); + + // Drawer 关闭时候的处理。1. 结束的时候不管动效是不是已经结束都要隐藏弹窗;2. 需要把focus态还原到触发节点 + const handleExited = () => { + if (!isAnimationEnd.current) { + markAnimationEnd(true); + dom.setStyle(wrapperRef.current!, 'display', 'none'); + scrollLocker.unlock(document.body, locker.current!); + + if (autoFocus && lastFocus.current) { + try { + lastFocus.current.focus(); + } finally { + // ignore ... + } + lastFocus.current = null; + } + afterClose(); + } + }; + + // visible? : null; 这种写法会触发卸载 + useEffect(() => { + return () => { + handleExited(); + }; + }, []); + + if (firstVisible === false || !container) { + return null; + } + + if (!visible && !cache && isAnimationEnd.current) { + return null; + } + + const handleMaskClick = (e: React.MouseEvent | KeyboardEvent) => { + if (!canCloseByMask) { + return; + } + + handleClose('maskClick', e); + }; + + const handleEnter = () => { + markAnimationEnd(false); + dom.setStyle(wrapperRef.current!, 'display', ''); + }; + const handleEntered = () => { + if (autoFocus && drawerRef.current && drawerRef.current.bodyNode) { + const focusableNodes = focus.getFocusNodeList(drawerRef.current.bodyNode); + if (focusableNodes.length > 0 && focusableNodes[0]) { + lastFocus.current = document.activeElement as HTMLElement; + const firstFocusableNode = focusableNodes[0] as HTMLElement; + firstFocusableNode.focus(); + } + } + setVisibleOverlayToParent(uuid, drawerRef.current); + }; + + const wrapperCls = classNames({ + [`${prefix}overlay-wrapper`]: true, + opened: visible, + }); + const innerWrapperCls = classNames({ + [`${prefix}overlay-inner`]: true, + [`${prefix}drawer-wrapper`]: true, + [`${prefix}drawer-${placement}`]: true, + [className!]: !!className, + }); + const drawerCls = classNames({ + [`${prefix}drawer-v2`]: true, + [className!]: !!className, + }); + + const newAnimation: DrawerV2Props['animation'] = + animation === null || animation === false + ? undefined + : animation + ? animation + : getAnimation(placement); + + const timeout = { + appear: 300, + enter: 300, + exit: 250, + }; + + const getVisibleOverlayFromChild = (id: string, node: HTMLElement) => { + if (node) { + childIDMap.current.set(id, node); + } else { + childIDMap.current.delete(id); + } + // 让父级也感知 + setVisibleOverlayToParent(id, node); + }; + + const nstyle = { + width, + height, + ...style, + }; + + return ( + + {ReactDOM.createPortal( +
    + {hasMask ? ( + +
    + + ) : null} + +
    + + handleClose('closeClick', ...args)} + > + {children} + + +
    +
    , + container + )} + + ); +}; + +Drawer.displayName = 'Drawer'; + +export default Drawer; diff --git a/components/drawer/drawer.jsx b/components/drawer/drawer.jsx deleted file mode 100644 index 25d71b148f..0000000000 --- a/components/drawer/drawer.jsx +++ /dev/null @@ -1,307 +0,0 @@ -import PropTypes from 'prop-types'; -import React, { Component } from 'react'; -import Overlay from '../overlay'; -import Inner from './inner'; -import zhCN from '../locale/zh-cn'; -import { obj } from '../util'; - -const noop = () => {}; -const { Popup } = Overlay; -const { pickOthers } = obj; - -/** - * Drawer - * @description 继承 Overlay.Popup 的 API,除非特别说明 - * */ -export default class Drawer extends Component { - static displayName = 'Drawer'; - - static propTypes = { - ...(Popup.propTypes || {}), - prefix: PropTypes.string, - pure: PropTypes.bool, - rtl: PropTypes.bool, - // 不建议使用trigger - trigger: PropTypes.element, - triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - /** - * 宽度,仅在 placement是 left right 的时候生效 - */ - width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** - * 高度,仅在 placement是 top bottom 的时候生效 - */ - height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** - * [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 'close,esc,mask' - * 如果设置为 true,则以上关闭方式全部生效 - * 如果设置为 false,则以上关闭方式全部失效 - */ - closeable: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), - /** - * 隐藏时是否保留子节点,不销毁 - */ - cache: PropTypes.bool, - /** - * [推荐]控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 ['close','esc','mask'], [] - * @version 1.21 - */ - closeMode: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.oneOf(['close', 'mask', 'esc'])), - PropTypes.oneOf(['close', 'mask', 'esc']), - ]), - /** - * 对话框关闭时触发的回调函数 - * @param {String} trigger 关闭触发行为的描述字符串 - * @param {Object} event 关闭时事件对象 - */ - onClose: PropTypes.func, - /** - * [v2废弃]对话框打开后的回调函数 - */ - afterOpen: PropTypes.func, - /** - * 位于页面的位置 - */ - placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), - /** - * 标题 - */ - title: PropTypes.node, - /** - * header上的样式 - */ - headerStyle: PropTypes.object, - /** - * body上的样式 - */ - bodyStyle: PropTypes.object, - /** - * 是否显示 - */ - visible: PropTypes.bool, - /** - * 是否显示遮罩 - */ - hasMask: PropTypes.bool, - // 受控模式下(没有 trigger 的时候),只会在关闭时触发,相当于onClose - onVisibleChange: PropTypes.func, - /** - * 显示隐藏时动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画。 请参考 Animate 组件的文档获取可用的动画名 - * @default { in: 'expandInDown', out: 'expandOutUp' } - */ - animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - locale: PropTypes.object, - // for ConfigProvider - popupContainer: PropTypes.any, - /** - * 开启 v2 - */ - v2: PropTypes.bool, - /** - * [v2] 弹窗关闭后的回调 - */ - afterClose: PropTypes.func, - }; - - static defaultProps = { - prefix: 'next-', - triggerType: 'click', - trigger: null, - closeable: true, - onClose: noop, - hasMask: true, - placement: 'right', - locale: zhCN.Drawer, - }; - - getAlign = placement => { - let align; - switch (placement) { - case 'top': - align = 'tl tl'; - break; - case 'bottom': - align = 'bl bl'; - break; - case 'left': - align = 'tl tl'; - break; - case 'right': - default: - align = 'tr tr'; - break; - } - - return align; - }; - - getAnimation = placement => { - if ('animation' in this.props) { - return this.props.animation; - } - - let animation; - switch (placement) { - case 'top': - animation = { - in: 'slideInDown', - out: 'slideOutUp', - }; - break; - case 'bottom': - animation = { - in: 'slideInUp', - out: 'slideOutDown', - }; - break; - case 'left': - animation = { - in: 'slideInLeft', - out: 'slideOutLeft', - }; - break; - case 'right': - default: - animation = { - in: 'slideInRight', - out: 'slideOutRight', - }; - break; - } - - return animation; - }; - - getOverlayRef = ref => { - this.overlay = ref; - }; - - mapcloseableToConfig = closeable => { - return ['esc', 'close', 'mask'].reduce((ret, option) => { - const key = option.charAt(0).toUpperCase() + option.substr(1); - const value = typeof closeable === 'boolean' ? closeable : closeable.split(',').indexOf(option) > -1; - - if (option === 'esc' || option === 'mask') { - ret[`canCloseBy${key}`] = value; - } else { - ret[`canCloseBy${key}Click`] = value; - } - - return ret; - }, {}); - }; - - handleVisibleChange = (visible, reason, e) => { - const { onClose, onVisibleChange } = this.props; - - if (visible === false) { - onClose && onClose(reason, e); - } - - onVisibleChange && onVisibleChange(visible, reason, e); - }; - - renderInner(closeable) { - const { - prefix, - className, - children, - title, - onClose, - locale, - headerStyle, - bodyStyle, - placement, - rtl, - } = this.props; - const others = pickOthers(Object.keys(Drawer.propTypes), this.props); - - return ( - - {children} - - ); - } - - render() { - const { - prefix, - style, - width, - height, - trigger, - triggerType, - animation, - hasMask, - visible, - placement, - onClose, - onVisibleChange, - closeable, - closeMode, - rtl, - popupContainer, - ...others - } = this.props; - - const newStyle = { - width, - height, - ...style, - }; - - const newCloseable = - 'closeMode' in this.props ? (Array.isArray(closeMode) ? closeMode.join(',') : closeMode) : closeable; - - const { canCloseByCloseClick, ...closeConfig } = this.mapcloseableToConfig(newCloseable); - - const newPopupProps = { - prefix, - visible, - trigger, - triggerType, - onVisibleChange: this.handleVisibleChange, - animation: this.getAnimation(placement), - hasMask, - align: this.getAlign(placement), - ...closeConfig, - canCloseByOutSideClick: false, - disableScroll: true, - ref: this.getOverlayRef, - rtl, - target: 'viewport', - style: newStyle, - needAdjust: false, - container: popupContainer, - }; - - const inner = this.renderInner(canCloseByCloseClick); - - return ( - - {inner} - - ); - } -} diff --git a/components/drawer/drawer.tsx b/components/drawer/drawer.tsx new file mode 100644 index 0000000000..db217dac95 --- /dev/null +++ b/components/drawer/drawer.tsx @@ -0,0 +1,262 @@ +import PropTypes from 'prop-types'; +import React, { Component, type ComponentRef, type ComponentType } from 'react'; +import Overlay from '../overlay'; +import Inner from './inner'; +import zhCN from '../locale/zh-cn'; +import { obj } from '../util'; +import type { DrawerProps, InnerProps } from './types'; + +const noop: InnerProps['onClose'] = () => {}; +const { Popup } = Overlay; +const { pickOthers } = obj; + +interface CloseConfig { + canCloseByEsc?: boolean; + canCloseByCloseClick?: boolean; + canCloseByMask?: boolean; +} + +/** + * Drawer + * 继承 Overlay.Popup 的 API,除非特别说明 + * */ +export default class Drawer extends Component { + static displayName = 'Drawer'; + + static propTypes = { + ...((Popup as ComponentType).propTypes || {}), + prefix: PropTypes.string, + pure: PropTypes.bool, + rtl: PropTypes.bool, + trigger: PropTypes.element, + triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + width: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + height: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), + closeable: PropTypes.oneOfType([PropTypes.string, PropTypes.bool]), + cache: PropTypes.bool, + closeMode: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.oneOf(['close', 'mask', 'esc'])), + PropTypes.oneOf(['close', 'mask', 'esc']), + ]), + onClose: PropTypes.func, + afterOpen: PropTypes.func, + placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + title: PropTypes.node, + headerStyle: PropTypes.object, + bodyStyle: PropTypes.object, + visible: PropTypes.bool, + hasMask: PropTypes.bool, + onVisibleChange: PropTypes.func, + animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + locale: PropTypes.object, + popupContainer: PropTypes.any, + v2: PropTypes.bool, + afterClose: PropTypes.func, + }; + + static defaultProps = { + prefix: 'next-', + triggerType: 'click', + trigger: null, + closeable: true, + onClose: noop, + hasMask: true, + placement: 'right', + locale: zhCN.Drawer, + }; + + private overlay: ComponentRef | null = null; + + getAlign = (placement: string | undefined) => { + let align; + switch (placement) { + case 'top': + align = 'tl tl'; + break; + case 'bottom': + align = 'bl bl'; + break; + case 'left': + align = 'tl tl'; + break; + case 'right': + default: + align = 'tr tr'; + break; + } + + return align; + }; + + getAnimation = (placement: string | undefined) => { + if ('animation' in this.props) { + return this.props.animation; + } + + let animation; + switch (placement) { + case 'top': + animation = { + in: 'slideInDown', + out: 'slideOutUp', + }; + break; + case 'bottom': + animation = { + in: 'slideInUp', + out: 'slideOutDown', + }; + break; + case 'left': + animation = { + in: 'slideInLeft', + out: 'slideOutLeft', + }; + break; + case 'right': + default: + animation = { + in: 'slideInRight', + out: 'slideOutRight', + }; + break; + } + + return animation; + }; + + getOverlayRef = (ref: ComponentRef | null) => { + this.overlay = ref; + }; + + mapcloseableToConfig = (closeable: boolean | string): CloseConfig => { + return ['esc', 'close', 'mask'].reduce((ret: CloseConfig, option) => { + const key = option.charAt(0).toUpperCase() + option.substr(1); + const value = + typeof closeable === 'boolean' + ? closeable + : closeable.split(',').indexOf(option) > -1; + + if (option === 'esc' || option === 'mask') { + ret[`canCloseBy${key}` as keyof CloseConfig] = value; + } else { + ret[`canCloseBy${key}Click` as keyof CloseConfig] = value; + } + + return ret; + }, {}); + }; + + handleVisibleChange = (visible: boolean, reason: string, e: React.MouseEvent) => { + const { onClose, onVisibleChange } = this.props; + + if (visible === false) { + onClose && onClose(reason, e); + } + + onVisibleChange && onVisibleChange(visible, reason, e); + }; + + renderInner(closeable: InnerProps['closeable']) { + const { + prefix, + className, + children, + title, + onClose, + locale, + headerStyle, + bodyStyle, + placement, + rtl, + } = this.props; + const others = pickOthers(Drawer.propTypes, this.props); + + return ( + + {children} + + ); + } + + render() { + const { + prefix, + style, + width, + height, + trigger, + triggerType, + animation, + hasMask, + visible, + placement, + onClose, + onVisibleChange, + closeable, + closeMode, + rtl, + popupContainer, + content, + title, + ...others + } = this.props; + + const newStyle = { + width, + height, + ...style, + }; + + const newCloseable = + 'closeMode' in this.props + ? Array.isArray(closeMode) + ? closeMode.join(',') + : closeMode + : closeable; + + const { canCloseByCloseClick, ...closeConfig } = this.mapcloseableToConfig( + newCloseable as boolean | string + ); + + const newPopupProps = { + prefix, + visible, + trigger, + triggerType, + onVisibleChange: this.handleVisibleChange, + animation: this.getAnimation(placement), + hasMask, + align: this.getAlign(placement), + ...closeConfig, + canCloseByOutSideClick: false, + disableScroll: true, + ref: this.getOverlayRef, + rtl, + target: 'viewport', + style: newStyle, + needAdjust: false, + container: popupContainer, + }; + + const inner = this.renderInner(canCloseByCloseClick); + + return ( + + {inner} + + ); + } +} diff --git a/components/drawer/index.d.ts b/components/drawer/index.d.ts deleted file mode 100644 index aa241b3d5f..0000000000 --- a/components/drawer/index.d.ts +++ /dev/null @@ -1,93 +0,0 @@ -/// - -import React from 'react'; -import { PopupProps } from '../overlay'; -import { CloseMode } from '../dialog'; -import { CommonProps } from '../util'; - -interface HTMLAttributesWeak extends PopupProps { - title?: any; - onClose?: any; -} - -export interface DrawerProps extends Omit, CommonProps { - /** - * [废弃]同closeMode, 控制对话框关闭的方式,值可以为字符串或者布尔值,其中字符串是由以下值组成: - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'mask' 或 'esc,mask' - * 如果设置为 true,则以上关闭方式全部生效 - * 如果设置为 false,则以上关闭方式全部失效 - * @deprecated - */ - closeable?: 'close' | 'mask' | 'esc' | boolean | 'close,mask' | 'close,esc' | 'mask,esc'; - /** - * [推荐]控制对话框关闭的方式,值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: - * **close** 表示点击关闭按钮可以关闭对话框 - * **mask** 表示点击遮罩区域可以关闭对话框 - * **esc** 表示按下 esc 键可以关闭对话框 - * 如 'close' 或 ['close','esc','mask'], [] - */ - closeMode?: CloseMode[] | 'close' | 'mask' | 'esc'; - /** - * 隐藏时是否保留子节点,不销毁 - */ - cache?: boolean; - /** - * 标题 - */ - title?: React.ReactNode; - /** - * body上的样式 - */ - bodyStyle?: React.CSSProperties; - headerStyle?: React.CSSProperties; - /** - * 显示隐藏时动画的播放方式 - * @property {String} in 进场动画 - * @property {String} out 出场动画 - */ - animation?: { in: string; out: string } | boolean; - visible?: boolean; - - /** - * 宽度,仅在 placement是 left right 的时候生效 - */ - width?: number | string; - - /** - * 高度,仅在 placement是 top bottom 的时候生效 - */ - height?: number | string; - /** - * [v2 废弃] 受控模式下(没有 trigger 的时候),只会在关闭时触发,相当于onClose - * @deprecated - */ - onVisibleChange?: (visible: boolean, reason: string) => void; - /** - * [v2] 弹窗关闭后的回调 - */ - afterClose?: () => void; - onClose?: (reason: string, e: React.MouseEvent) => void; - /** - * 位于页面的位置 - */ - placement?: 'top' | 'right' | 'bottom' | 'left'; - /** - * 开启v2版本 - */ - v2?: boolean; - - /** - * 内容 - */ - content?: React.ReactNode; -} - -export interface QuickShowRet { - hide: () => void; -} - -export default class Drawer extends React.Component { - static show(config: DrawerProps): QuickShowRet; -} diff --git a/components/drawer/index.jsx b/components/drawer/index.jsx deleted file mode 100644 index 41e65d37a4..0000000000 --- a/components/drawer/index.jsx +++ /dev/null @@ -1,26 +0,0 @@ -import React from 'react'; - -import ConfigProvider from '../config-provider'; -import Drawer1 from './drawer'; -import Drawer2 from './drawer-v2'; - -import Inner from './inner'; - -import { show, withContext } from './show'; - -class Drawer extends React.Component { - render() { - const { v2, ...others } = this.props; - if (v2) { - return ; - } else { - return ; - } - } -} - -Drawer.Inner = Inner; -Drawer.show = show; -Drawer.withContext = withContext; - -export default ConfigProvider.config(Drawer); diff --git a/components/drawer/index.tsx b/components/drawer/index.tsx new file mode 100644 index 0000000000..6c782ee926 --- /dev/null +++ b/components/drawer/index.tsx @@ -0,0 +1,39 @@ +import React from 'react'; + +import ConfigProvider from '../config-provider'; +import Drawer1 from './drawer'; +import Drawer2 from './drawer-v2'; + +import Inner from './inner'; + +import { show, withContext } from './show'; +import type { DrawerV2Props, DrawerV1Props } from './types'; + +export interface QuickShowRet { + hide: () => void; +} + +export type DrawerProps = DrawerV2Props | DrawerV1Props; + +class Drawer extends React.Component { + static Inner: typeof Inner; + static show: (config?: DrawerProps) => QuickShowRet; + static withContext:

    ( + WrappedComponent: React.ComponentType

    + ) => React.ComponentType

    ; + + render() { + const { v2, ...others } = this.props; + if (v2) { + return ; + } else { + return ; + } + } +} + +Drawer.Inner = Inner; +Drawer.show = show; +Drawer.withContext = withContext; + +export default ConfigProvider.config(Drawer); diff --git a/components/drawer/inner.jsx b/components/drawer/inner.jsx deleted file mode 100644 index ea8963b8d2..0000000000 --- a/components/drawer/inner.jsx +++ /dev/null @@ -1,117 +0,0 @@ -import React, { Component } from 'react'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import Icon from '../icon'; -import zhCN from '../locale/zh-cn'; -import { obj } from '../util'; - -const noop = () => {}; -const { pickOthers } = obj; - -export default class Inner extends Component { - static propTypes = { - prefix: PropTypes.string, - className: PropTypes.string, - closeable: PropTypes.bool, - role: PropTypes.string, - title: PropTypes.node, - placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), - rtl: PropTypes.bool, - onClose: PropTypes.func, - locale: PropTypes.object, - headerStyle: PropTypes.object, - bodyStyle: PropTypes.object, - afterClose: PropTypes.func, - beforeOpen: PropTypes.func, - beforeClose: PropTypes.func, - cache: PropTypes.bool, - shouldUpdatePosition: PropTypes.bool, - v2: PropTypes.bool, - }; - - static defaultProps = { - prefix: 'next-', - closeable: true, - role: 'dialog', - onClose: noop, - locale: zhCN.Drawer, - }; - - renderHeader() { - const { prefix, title, headerStyle } = this.props; - const closeLink = this.renderCloseLink(); - const headerCls = cx({ - [`${prefix}drawer-header`]: true, - [`${prefix}drawer-no-title`]: !title, - }); - - return ( -

    - {title} - {closeLink} -
    - ); - } - - renderBody() { - const { prefix, children, bodyStyle } = this.props; - if (children) { - return ( -
    - {children} -
    - ); - } - return null; - } - - renderCloseLink() { - const { prefix, closeable, onClose, locale } = this.props; - - if (closeable) { - return ( - - - - ); - } - - return null; - } - - render() { - const { prefix, className, closeable, placement, role, rtl, v2 } = this.props; - - const others = pickOthers(Object.keys(Inner.propTypes), this.props); - const newClassName = cx({ - [`${prefix}drawer`]: true, - [`${prefix}drawer-${placement}`]: !v2, - [`${prefix}closeable`]: closeable, - [className]: !!className, - }); - - const ariaProps = { - role, - 'aria-modal': 'true', - }; - - const header = this.renderHeader(); - const body = this.renderBody(); - - return ( -
    - {v2 ? ( -
    - {header} - {body} -
    - ) : ( -
    - {header} - {body} -
    - )} -
    - ); - } -} diff --git a/components/drawer/inner.tsx b/components/drawer/inner.tsx new file mode 100644 index 0000000000..67f837b91d --- /dev/null +++ b/components/drawer/inner.tsx @@ -0,0 +1,133 @@ +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import cx from 'classnames'; +import Icon from '../icon'; +import zhCN from '../locale/zh-cn'; +import { obj } from '../util'; +import type { InnerProps } from './types'; + +const noop = () => {}; +const { pickOthers } = obj; + +interface ariaRoleProps { + role?: string; + 'aria-modal'?: boolean | 'true' | 'false'; + 'aria-level'?: number; + 'aria-label'?: string; +} + +export default class Inner extends Component { + static propTypes = { + prefix: PropTypes.string, + className: PropTypes.string, + closeable: PropTypes.bool, + role: PropTypes.string, + title: PropTypes.node, + placement: PropTypes.oneOf(['top', 'right', 'bottom', 'left']), + rtl: PropTypes.bool, + onClose: PropTypes.func, + locale: PropTypes.object, + headerStyle: PropTypes.object, + bodyStyle: PropTypes.object, + afterClose: PropTypes.func, + beforeOpen: PropTypes.func, + beforeClose: PropTypes.func, + cache: PropTypes.bool, + shouldUpdatePosition: PropTypes.bool, + v2: PropTypes.bool, + }; + + static defaultProps = { + prefix: 'next-', + closeable: true, + role: 'dialog', + onClose: noop, + locale: zhCN.Drawer, + }; + + renderHeader() { + const { prefix, title, headerStyle } = this.props; + const closeLink = this.renderCloseLink(); + const headerCls = cx({ + [`${prefix}drawer-header`]: true, + [`${prefix}drawer-no-title`]: !title, + }); + const ariaProps: ariaRoleProps = { + role: 'heading', + 'aria-level': 1, + }; + + return ( +
    + {title} + {closeLink} +
    + ); + } + + renderBody() { + const { prefix, children, bodyStyle } = this.props; + if (children) { + return ( +
    + {children} +
    + ); + } + return null; + } + + renderCloseLink() { + const { prefix, closeable, onClose, locale } = this.props; + const ariaProps: ariaRoleProps = { + role: 'button', + 'aria-label': locale?.close as string, + }; + + if (closeable) { + return ( + + + + ); + } + + return null; + } + + render() { + const { prefix, className, closeable, placement, role, rtl, v2 } = this.props; + + const others = pickOthers(Object.keys(Inner.propTypes), this.props); + const newClassName = cx({ + [`${prefix}drawer`]: true, + [`${prefix}drawer-${placement}`]: !v2, + [`${prefix}closeable`]: closeable, + [className!]: !!className, + }); + + const ariaProps: ariaRoleProps = { + role, + 'aria-modal': 'true', + }; + + const header = this.renderHeader(); + const body = this.renderBody(); + + return ( +
    + {v2 ? ( +
    + {header} + {body} +
    + ) : ( +
    + {header} + {body} +
    + )} +
    + ); + } +} diff --git a/components/drawer/mobile/index.jsx b/components/drawer/mobile/index.tsx similarity index 100% rename from components/drawer/mobile/index.jsx rename to components/drawer/mobile/index.tsx diff --git a/components/drawer/show.jsx b/components/drawer/show.jsx deleted file mode 100644 index 512ca632a5..0000000000 --- a/components/drawer/show.jsx +++ /dev/null @@ -1,99 +0,0 @@ -import React, { Component } from 'react'; -import ReactDOM from 'react-dom'; -import PropTypes from 'prop-types'; -import cx from 'classnames'; -import ConfigProvider from '../config-provider'; -import Drawer from './drawer-v2'; - -class Modal extends React.Component { - state = { - visible: true, - loading: false, - }; - - close = () => { - this.setState({ - visible: false, - }); - }; - - render() { - const { visible, content, ...others } = this.props; - return ( - - {content} - - ); - } -} - -const ConfigModal = ConfigProvider.config(Modal, { componentName: 'Drawer' }); - -/** - * 创建对话框 - * @exportName show - * @param {Object} config 配置项 - * @returns {Object} 包含有 hide 方法,可用来关闭对话框 - */ -export const show = (config = {}) => { - const container = document.createElement('div'); - const unmount = () => { - if (config.afterClose) { - config.afterClose(); - } - ReactDOM.unmountComponentAtNode(container); - container.parentNode.removeChild(container); - }; - - document.body.appendChild(container); - let newContext = config.contextConfig; - if (!newContext) newContext = ConfigProvider.getContext(); - - let instance, myRef; - - const handleClose = () => { - const inc = instance && instance.getInstance(); - inc && inc.close(); - if (config.onClose) { - config.onClose(); - } - }; - - ReactDOM.render( - - { - myRef = ref; - }} - /> - , - container, - function() { - instance = myRef; - } - ); - return { - hide: handleClose, - }; -}; - -export const withContext = WrappedComponent => { - const HOC = props => { - return ( - - {contextConfig => ( - show({ ...config, contextConfig }), - }} - /> - )} - - ); - }; - return HOC; -}; diff --git a/components/drawer/show.tsx b/components/drawer/show.tsx new file mode 100644 index 0000000000..ff135bc176 --- /dev/null +++ b/components/drawer/show.tsx @@ -0,0 +1,130 @@ +import React, { type JSXElementConstructor } from 'react'; +import ReactDOM from 'react-dom'; +import ConfigProvider from '../config-provider'; +import Drawer from './drawer-v2'; +import type { DrawerV2Props } from './types'; +import type { AnyProps } from '../config-provider/config'; +import type { ConsumerState } from '../config-provider/consumer'; + +interface ModalState { + visible?: boolean; + loading?: boolean; +} + +class Modal extends React.Component { + state = { + visible: true, + loading: false, + }; + + close = () => { + this.setState({ + visible: false, + }); + }; + + render() { + const { visible, content, ...others } = this.props; + return ( + + {content} + + ); + } +} + +const ConfigModal = ConfigProvider.config(Modal, { componentName: 'Drawer' }); + +export type Config = DrawerV2Props & { + afterClose?: () => void; + onClose?: () => void; + contextConfig?: ConsumerState; +}; + +/** + * 创建对话框。 + * + * @remarks + * 该函数导出的名字是 `show`。 + * + * @param config - 配置项。 + * @returns 返回一个对象,该对象包含有 `hide` 方法,可用来关闭对话框。 + */ +export const show = (config: Config = {}) => { + const container: HTMLDivElement = document.createElement('div'); + + const unmount = () => { + if (config.afterClose) { + config.afterClose(); + } + // eslint-disable-next-line react/no-deprecated + ReactDOM.unmountComponentAtNode(container); + container.parentNode?.removeChild(container); + }; + + document.body.appendChild(container); + let newContext = config.contextConfig; + if (!newContext) newContext = ConfigProvider.getContext(); + + let instance: InstanceType | null, + myRef: InstanceType | null; + + const handleClose = () => { + const inc = instance && instance.getInstance(); + inc && inc.close(); + if (config.onClose) { + config.onClose(); + } + }; + + // eslint-disable-next-line react/no-deprecated + ReactDOM.render( + + { + myRef = ref; + }} + /> + , + container, + function () { + instance = myRef; + } + ); + return { + hide: handleClose, + }; +}; + +export interface ContextDialog { + show: (config?: Config) => { hide: () => void }; +} + +export interface WithContextDrawerProps { + contextDialog: ContextDialog; +} + +export const withContext =

    ( + WrappedComponent: JSXElementConstructor

    & C +) => { + type Props = React.JSX.LibraryManagedAttributes>; + const HOC = (props: Props) => { + return ( + + {contextConfig => ( + show({ ...config, contextConfig }), + }} + /> + )} + + ); + }; + return HOC; +}; diff --git a/components/drawer/style.js b/components/drawer/style.ts similarity index 100% rename from components/drawer/style.js rename to components/drawer/style.ts diff --git a/components/drawer/types.ts b/components/drawer/types.ts new file mode 100644 index 0000000000..291875658e --- /dev/null +++ b/components/drawer/types.ts @@ -0,0 +1,330 @@ +import type React from 'react'; +import type { PopupProps } from '../overlay'; +import type { CommonProps } from '../util'; +import type { ComponentLocaleObject } from '../locale/types'; + +/** + * @api + */ +export type CloseMode = 'close' | 'mask' | 'esc'; + +/** + * @api Drawer + */ +export interface DrawerV1Props + extends Omit, + CommonProps { + /** + * [废弃] 同 closeMode, 控制对话框关闭的方式, + * @en [Deprecated] Control the way the drawer is closed + * @deprecated 由于设计变更,该属性已被弃用。请使用 `closeMode` 属性来控制对话框关闭的方式。 + * @defaultValue true + * @remarks + * 值可以为字符串或者布尔值,其中字符串是由以下值组成: + * **close** 表示点击关闭按钮可以关闭对话框, + * **mask** 表示点击遮罩区域可以关闭对话框, + * **esc** 表示按下 esc 键可以关闭对话框, + * 如 'mask' 或 'esc,mask', + * 如果设置为 true,则以上关闭方式全部生效, + * 如果设置为 false,则以上关闭方式全部失效。 + * - + * The value can be a string or a Boolean value, where the string is composed of the following values: + * **close** (Click the close button to close the drawer), + * **mask** (Click the mask area to close the drawer), + * **esc** (Press the esc key to close the drawer), + * For example: 'close' or 'close,esc,mask', [], + * If set to true, the above close modes are all effective, + * If set to false, the above close modes are all invalid. + */ + closeable?: 'close' | 'mask' | 'esc' | boolean | 'close,mask' | 'close,esc' | 'mask,esc'; + /** + * [推荐] 控制对话框关闭的方式 + * @en Control the way the dialog is closed + * @version 1.21 + * @remarks + * 值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: + * **close** 表示点击关闭按钮可以关闭对话框, + * **mask** 表示点击遮罩区域可以关闭对话框, + * **esc** 表示按下 esc 键可以关闭对话框, + * 如 'close' 或 ['close','esc','mask'], []。 + * - + * The value can be a string or array, where the string and array are enumerated values of the following: + * **close** (Click the close button to close the dialog), + * **mask** (Click the mask area to close the dialog), + * **esc** (Press the esc key to close the dialog), + * For example: 'close' or ['close','esc','mask'], []. + */ + closeMode?: CloseMode | CloseMode[]; + /** + * 隐藏时是否保留子节点,不销毁 + * @en Whether to retain the child node when hiding + */ + cache?: boolean; + /** + * 标题 + * @en Title + */ + title?: React.ReactNode; + /** + * body 上的样式 + * @en Style on body + */ + bodyStyle?: React.CSSProperties; + /** + * header 上的样式 + * @en Style on header + */ + headerStyle?: React.CSSProperties; + /** + * 显示隐藏时动画的播放方式 + * @en Animation playback method when showing and hiding + * @defaultValue \{ in: 'expandInDown', out: 'expandOutUp' \} + * @remarks + * `animation` 对象包含两个属性:`in` 和 `out`。 + * - `in`: 进场动画 + * - `out`: 出场动画 + * @param animation - 指定进场和出场动画的对象。 + */ + animation?: { in: string; out: string } | false; + /** + * 是否显示 + * @en Whether to show + */ + visible?: boolean; + /** + * 宽度,仅在 placement 是 left right 的时候生效 + * @en Width, only effective when placement is left right + */ + width?: number | string; + /** + * 高度,仅在 placement 是 top bottom 的时候生效 + * @en Height, only effective when placement is the top bottom + */ + height?: number | string; + /** + * 对话框关闭时触发的回调函数 + * @en Callback when the dialog is closed + * @defaultValue `() => {}` + */ + onClose?: (reason: string, e: React.MouseEvent | KeyboardEvent) => void; + /** + * 位于页面的位置 + * @en The position of the page + * @defaultValue 'right' + */ + placement?: 'top' | 'right' | 'bottom' | 'left'; + /** + * 开启 v2 + * @en Enable v2 version + * @defaultValue false + */ + v2?: false | undefined; + /** + * 内容 + * @en Content + */ + content?: React.ReactNode; + /** + * 子元素 + * @skip + * @en Child elements + */ + children?: React.ReactNode; + /** + * 渲染组件的容器 + * @en Render component container + * @remarks + * 如果是函数需要返回 ref, + * 如果是字符串则是该 DOM 的 id, + * 也可以直接传入 DOM 节点。 + * - + * If it is a function, it needs to return ref, + * if it is a string, it is the id of the DOM, + * or you can directly pass in DOM nodes + */ + popupContainer?: string | HTMLElement | null; + /** + * 是否显示遮罩 + * @en Whether there is a mask + * @defaultValue true + */ + hasMask?: boolean; + /** + * [v2 废弃] 对话框打开后的回调函数 + * @en Callback after the dialog is opened + */ + afterOpen?: () => void; + /** + * [v2 废弃] 受控模式下 (没有 trigger 的时候),只会在关闭时触发,相当于 onClose + * @en [v2 Deprecated] Controlled mode (without trigger), only triggered when closed, equivalent to onClose + * @remarks + * 该属性在 v2 版本已被废弃,不再推荐使用。 + * 请改用 `onClose` 事件处理器来处理关闭事件。 + * - + * This attribute has been deprecated in version v2 and is no longer recommended for use. + * Please use the 'onClose' event handler to handle the shutdown event instead. + */ + onVisibleChange?: (visible: boolean, reason: string, e?: React.MouseEvent) => void; +} + +/** + * @api Drawer V2 + */ +export interface DrawerV2Props + extends Omit, + CommonProps { + /** + * [废弃] 同 closeMode, 控制对话框关闭的方式, + * @en [Deprecated] Control the way the drawer is closed + * @deprecated 由于设计变更,该属性已被弃用。请使用 `closeMode` 属性来控制对话框关闭的方式。 + * @defaultValue true + * @remarks + * 值可以为字符串或者布尔值,其中字符串是由以下值组成: + * **close** 表示点击关闭按钮可以关闭对话框, + * **mask** 表示点击遮罩区域可以关闭对话框, + * **esc** 表示按下 esc 键可以关闭对话框, + * 如 'mask' 或 'esc,mask', + * 如果设置为 true,则以上关闭方式全部生效, + * 如果设置为 false,则以上关闭方式全部失效。 + * - + * The value can be a string or a Boolean value, where the string is composed of the following values: + * **close** (Click the close button to close the drawer), + * **mask** (Click the mask area to close the drawer), + * **esc** (Press the esc key to close the drawer), + * For example: 'close' or 'close,esc,mask', [], + * If set to true, the above close modes are all effective, + * If set to false, the above close modes are all invalid. + */ + closeable?: 'close' | 'mask' | 'esc' | boolean | 'close,mask' | 'close,esc' | 'mask,esc'; + /** + * [推荐] 控制对话框关闭的方式 + * @en Control the way the dialog is closed + * @version 1.21 + * @remarks + * 值可以为字符串或者数组,其中字符串、数组均为以下值的枚举: + * **close** 表示点击关闭按钮可以关闭对话框, + * **mask** 表示点击遮罩区域可以关闭对话框, + * **esc** 表示按下 esc 键可以关闭对话框, + * 如 'close' 或 ['close','esc','mask'], []。 + * - + * The value can be a string or array, where the string and array are enumerated values of the following: + * **close** (Click the close button to close the dialog), + * **mask** (Click the mask area to close the dialog), + * **esc** (Press the esc key to close the dialog), + * For example: 'close' or ['close','esc','mask'], []. + */ + closeMode?: CloseMode | CloseMode[]; + /** + * 隐藏时是否保留子节点,不销毁 + * @en Whether to retain the child node when hiding + */ + cache?: boolean; + /** + * 标题 + * @en Title + */ + title?: React.ReactNode; + /** + * body 上的样式 + * @en Style on body + */ + bodyStyle?: React.CSSProperties; + /** + * header 上的样式 + * @en Style on header + */ + headerStyle?: React.CSSProperties; + /** + * 显示隐藏时动画的播放方式 + * @en Animation playback method when showing and hiding + * @defaultValue \{ in: 'expandInDown', out: 'expandOutUp' \} + * @remarks + * `animation` 对象包含两个属性:`in` 和 `out`。 + * - `in`: 进场动画 + * - `out`: 出场动画 + * @param animation - 指定进场和出场动画的对象。 + */ + animation?: { in: string; out: string } | false; + /** + * 是否显示 + * @en Whether to show + */ + visible?: boolean; + /** + * 宽度,仅在 placement 是 left right 的时候生效 + * @en Width, only effective when placement is left right + */ + width?: number | string; + /** + * 高度,仅在 placement 是 top bottom 的时候生效 + * @en Height, only effective when placement is the top bottom + */ + height?: number | string; + /** + * [v2] 弹窗关闭后的回调 + * @en Callback after the dialog is closed + */ + afterClose?: () => void; + /** + * 对话框关闭时触发的回调函数 + * @en Callback when the dialog is closed + * @defaultValue `() => {}` + */ + onClose?: (reason: string, e: React.MouseEvent | KeyboardEvent) => void; + /** + * 位于页面的位置 + * @en The position of the page + * @defaultValue 'right' + */ + placement?: 'top' | 'right' | 'bottom' | 'left'; + /** + * 开启 v2 + * @en Enable v2 version + * @defaultValue false + */ + v2?: true; + /** + * 内容 + * @en Content + */ + content?: React.ReactNode; + /** + * 子元素 + * @skip + * @en Child elements + */ + children?: React.ReactNode; + /** + * 渲染组件的容器 + * @en Render component container + * @remarks + * 如果是函数需要返回 ref, + * 如果是字符串则是该 DOM 的 id, + * 也可以直接传入 DOM 节点。 + * - + * If it is a function, it needs to return ref, + * if it is a string, it is the id of the DOM, + * or you can directly pass in DOM nodes + */ + popupContainer?: string | HTMLElement | null; + /** + * 是否显示遮罩 + * @en Whether there is a mask + * @defaultValue true + */ + hasMask?: boolean; +} + +export type DrawerProps = DrawerV2Props | DrawerV1Props; + +export interface InnerProps extends Omit { + prefix?: string; + className?: string | undefined; + role?: string; + rtl?: boolean | undefined; + onClose?: (e: React.MouseEvent) => void; + locale?: ComponentLocaleObject | undefined; + beforeOpen?: () => void; + beforeClose?: () => void; + shouldUpdatePosition?: boolean; +} diff --git a/components/dropdown/__docs__/demo/accessibility/index.md b/components/dropdown/__docs__/demo/accessibility/index.md index 551f39cfa3..0762ed7b2c 100644 --- a/components/dropdown/__docs__/demo/accessibility/index.md +++ b/components/dropdown/__docs__/demo/accessibility/index.md @@ -2,7 +2,7 @@ # 无障碍支持 -若要使用无障碍的Dropdown,推荐使用`` (请勿使用triggerType="focus")。菜单类元素需要由用户确认后再展开才是一种无障碍友好的实践。 +若要使用无障碍的 Dropdown,推荐使用`` (请勿使用 triggerType="focus")。菜单类元素需要由用户确认后再展开才是一种无障碍友好的实践。 # en-US order=3 diff --git a/components/dropdown/__docs__/demo/controlled/index.md b/components/dropdown/__docs__/demo/controlled/index.md index 88e8001ebf..792f399865 100644 --- a/components/dropdown/__docs__/demo/controlled/index.md +++ b/components/dropdown/__docs__/demo/controlled/index.md @@ -8,4 +8,4 @@ # Close the Overlay from Outside -You can set `visible` attribute to controll overlay display or hidden, and you should tell dropdown component what it controls by `safeNode` attibute. +You can set `visible` attribute to control overlay display or hidden, and you should tell dropdown component what it controls by `safeNode` attribute. diff --git a/components/dropdown/__docs__/demo/controlled/index.tsx b/components/dropdown/__docs__/demo/controlled/index.tsx index 7d0a13995b..ab45eb8290 100644 --- a/components/dropdown/__docs__/demo/controlled/index.tsx +++ b/components/dropdown/__docs__/demo/controlled/index.tsx @@ -22,7 +22,7 @@ class App extends React.Component { }); }; - onVisibleChange = visible => { + onVisibleChange = (visible: boolean) => { this.setState({ visible, }); diff --git a/components/dropdown/__docs__/index.en-us.md b/components/dropdown/__docs__/index.en-us.md index c9b520878e..10f2d30bca 100644 --- a/components/dropdown/__docs__/index.en-us.md +++ b/components/dropdown/__docs__/index.en-us.md @@ -20,32 +20,34 @@ You can storage operation command with dropdown component when there are too muc ## API ### Dropdown -> Dropdown component extends API of Popup component, unless special note. - -| Param | Descripiton | Type | Default Value | -| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------ | -| children | content in overlay | ReactNode | - | -| visible | overlay display or not now | Boolean | - | -| defaultVisible | overlay display or not in default situation | Boolean | false | -| onVisibleChange | callback function when toggle visible of overlay

    **signatures**:
    Function(visible: Boolean, type: String, e: Object) => void
    **params**:
    _visible_: {Boolean} overlay display or not
    _type_: {String} orign of trigger overlay toggle visible
    _e_: {Object} DOM Event| Function | func.noop | -| trigger | trigger element | ReactNode | - | -| triggerType | operation type of trigger overlay toggle visible

    **options**:
    'hover', 'click' | Enum | 'hover' | -| disabled | overlay can not toggle visible if you set disabled attribute | Boolean | false | -| align | overlay position relative to trigger element, see details Overlay align | String | 'tl bl' | -| offset | extra adjustment for trigger element. e.g. [hoz, ver] means move to right ${hoz}px (to left in RTL mode), to bottom ${ver}px | Array | [0, 0] | -| delay | delay time of toggle overlay visible(unit: ms),if triggerType value is 'hover', delay time will work | Number | 200 | -| autoFocus | let element in overlay get focus or not after overlay was opened | Boolean | true | -| hasMask | display mask or not | Boolean | false | -| cache | reserve child element or not after hidden overlay | Boolean | false | -| animation | animation play mode, support object value: { in: 'enter-class', out: 'leave-class' }, there is no animation if set `false` | Object/Boolean | { in: 'expandInDown', out: 'expandOutUp' } | + +继承 Popup 绝大多数属性,除了 canCloseByOutSideClick, autoFocus,以下列举为常用属性,其他可参考 Overlay 文档 + +Inherit most properties from Popup, except canCloseByOutSideClick, autoFocus, the following are common properties, other properties can refer to Overlay documentation + +| Param | Description | Type | Default Value | Required | +| --------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------- | -------- | +| autoClose | If set true, the popup will be closed when the child is clicked no matter whether it is a Menu (2.x default is true) | boolean | false | | +| children | Content in Dropdown | React.ReactElement | - | yes | +| visible | Overlay display or not now | boolean | - | | +| align | Overlay position relative to trigger element, see details Overlay align | string | 'tl bl' | | +| offset | Extra adjustment for trigger element. | Array\ | [0, 0] | | +| hasMask | Display mask or not | boolean | false | | +| animation | Animation play mode, support object value: \{ in: 'enter-class', out: 'leave | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'expandInDown', out: 'expandOutUp' \} | | +| trigger | Trigger element | React.ReactElement | - | yes | +| triggerType | Operation type of trigger overlay toggle visible, eg 'hover', 'click' | PopupProps['triggerType'] | 'hover' | | +| defaultVisible | Overlay display or not in default situation | boolean | false | | +| onVisibleChange | Callback function when toggle visible of overlay | PopupProps['onVisibleChange'] | - | | +| disabled | Overlay can not toggle visible if you set disabled attribute | PopupProps['disabled'] | false | | +| delay | Delay time of toggle overlay visible(unit: ms),if triggerType value is 'hover', delay time will work | PopupProps['delay'] | 200 | | ## ARIA and KeyBoard -| KeyBoard | Descripiton | -| :---------- | :------------------------------ | -| Up Arrow | in vertical mode, at the same level navigation, navigate to previous item | -| Down Arrow | in vertical mode, at the same level navigation, navigate to next item | +| KeyBoard | Description | +| :---------- | :------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Up Arrow | in vertical mode, at the same level navigation, navigate to previous item | +| Down Arrow | in vertical mode, at the same level navigation, navigate to next item | | Right Arrow | in vertical mode, open the submenu, navigate to the first item of the submenu; in horizontal mode, navigate at the same level, navigate to the next one | -| Left Arrow | in vertical mode, close the submenu, navigate to the parent menu; in horizontal mode, navigate at the same level, navigate to the previous one | -| Enter | open submenu and navigate to the first item of the submenu | -| Esc | close submenu and navigate to the parent menu item | +| Left Arrow | in vertical mode, close the submenu, navigate to the parent menu; in horizontal mode, navigate at the same level, navigate to the previous one | +| Enter | open submenu and navigate to the first item of the submenu | +| Esc | close submenu and navigate to the parent menu item | diff --git a/components/dropdown/__docs__/index.md b/components/dropdown/__docs__/index.md index ccc7792cb1..85757a8216 100644 --- a/components/dropdown/__docs__/index.md +++ b/components/dropdown/__docs__/index.md @@ -17,33 +17,31 @@ ### Dropdown -> 继承 Popup 的 API,除非特别说明 - -| 参数 | 说明 | 类型 | 默认值 | 版本支持 | -| --------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------- | ------------------------------------------ | ---- | -| children | 弹层内容 | ReactNode | - | | -| visible | 弹层当前是否显示 | Boolean | - | | -| defaultVisible | 弹层默认是否显示 | Boolean | false | | -| onVisibleChange | 弹层显示或隐藏时触发的回调函数

    **签名**:
    Function(visible: Boolean, type: String) => void
    **参数**:
    _visible_: {Boolean} 弹层是否显示
    _type_: {String} 触发弹层显示或隐藏的来源 fromContent 表示由Dropdown内容触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 | Function | func.noop | | -| trigger | 触发弹层显示或者隐藏的元素 | ReactNode | - | | -| triggerType | 触发弹层显示或隐藏的操作类型,可以是 'click','hover',或者它们组成的数组,如 ['hover', 'click'] | String/Array | 'hover' | | -| disabled | 设置此属性,弹层无法显示或隐藏 | Boolean | false | | -| align | 弹层相对于触发元素的定位, 详见 Overlay 的定位部分 | String | 'tl bl' | | -| offset | 弹层相对于trigger的定位的微调, 接收数组[hoz, ver], 表示弹层在 left / top 上的增量
    e.g. [100, 100] 表示往右(RTL 模式下是往左) 、下分布偏移100px | Array | [0, 0] | | -| delay | 弹层显示或隐藏的延时时间(以毫秒为单位),在 triggerType 被设置为 hover 时生效 | Number | 200 | | -| autoFocus | 弹层打开时是否让其中的元素自动获取焦点 | Boolean | - | | -| hasMask | 是否显示遮罩 | Boolean | false | | -| autoClose | 开启后,children 不管是不是Menu,点击后都默认关掉弹层(2.x默认设置为true) | Boolean | false | 1.23 | -| cache | 隐藏时是否保留子节点 | Boolean | false | | -| animation | 配置动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画 | Object/Boolean | { in: 'expandInDown', out: 'expandOutUp' } | | +继承 Popup 绝大多数属性,除了 canCloseByOutSideClick, autoFocus,以下列举为常用属性,其他可参考 Overlay 文档 + +| 参数 | 说明 | 类型 | 默认值 | 是否必填 | +| --------------- | -------------------------------------------------------------------------------------------------------------- | ------------------------------------------------- | -------------------------------------------- | -------- | +| autoClose | 开启后,children 不管是不是 Menu,点击后都默认关掉弹层(2.x 默认设置为 true) | boolean | false | | +| children | 弹层内容 | React.ReactElement | - | 是 | +| visible | 弹层当前是否显示 | boolean | - | | +| align | 弹层相对于触发元素的定位,详见 Overlay 的定位部分 | string | 'tl bl' | | +| offset | 弹层相对于触发元素定位的微调 | Array\ | [0, 0] | | +| hasMask | 是否显示遮罩 | boolean | false | | +| animation | 配置动画的播放方式,支持 \{in: 'enter-class', out: 'leave-class' \} 的对象参数,如果设置为 false,则不播放动画 | string \| false \| Record\<'in' \| 'out', string> | \{ in: 'expandInDown', out: 'expandOutUp' \} | | +| trigger | 触发弹层显示或者隐藏的元素 | React.ReactElement | - | 是 | +| triggerType | 触发弹层显示或隐藏的操作类型,可以是 'click','hover',或者它们组成的数组,如 ['hover', 'click'] | PopupProps['triggerType'] | 'hover' | | +| defaultVisible | 弹层默认是否显示 | boolean | false | | +| onVisibleChange | 弹层显示或隐藏时触发的回调函数 | PopupProps['onVisibleChange'] | - | | +| disabled | 设置此属性,弹层无法显示或隐藏 | PopupProps['disabled'] | false | | +| delay | 弹层显示或隐藏的延时时间(以毫秒为单位),在 triggerType 被设置为 hover 时生效 | PopupProps['delay'] | 200 | | ## 无障碍键盘操作指南 -| 按键 | 说明 | -| :---------- | :-------------------------------------- | -| Up Arrow | 垂直模式下,同级导航,导航到前一项 | -| Down Arrow | 垂直模式下,同级导航,导航到后一项 | +| 按键 | 说明 | +| :---------- | :----------------------------------------------------------------------------- | +| Up Arrow | 垂直模式下,同级导航,导航到前一项 | +| Down Arrow | 垂直模式下,同级导航,导航到后一项 | | Right Arrow | 垂直模式下,打开子菜单,导航到子菜单第一项;水平模式下,同级导航,导航到后一项 | -| Left Arrow | 垂直模式下,关闭子菜单,导航到父级菜单;水平模式下,同级导航,导航到前一项 | -| Enter | 打开子菜单,导航到子菜单第一项 | -| Esc | 关闭子菜单,导航到父级菜单 | +| Left Arrow | 垂直模式下,关闭子菜单,导航到父级菜单;水平模式下,同级导航,导航到前一项 | +| Enter | 打开子菜单,导航到子菜单第一项 | +| Esc | 关闭子菜单,导航到父级菜单 | diff --git a/components/dropdown/__tests__/a11y-spec.js b/components/dropdown/__tests__/a11y-spec.js deleted file mode 100644 index 074281a403..0000000000 --- a/components/dropdown/__tests__/a11y-spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import React from 'react'; -import Enzyme from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import Dropdown from '../index'; -import '../style'; -import { unmount, test, createContainer, testReact } from '../../util/__tests__/legacy/a11y/validate'; - -Enzyme.configure({ adapter: new Adapter() }); - -const portalContainerId = 'a11y-portal-id'; -let portalContainer; - -/* eslint-disable no-undef, react/jsx-filename-extension */ -describe('Dropdown A11y', () => { - let wrapper; - - afterEach(() => { - if (wrapper) { - wrapper.unmount(); - wrapper = null; - } - - if (portalContainer) { - portalContainer.remove(); - } - - unmount(); - }); - - it('should not have any violations', async () => { - portalContainer = createContainer(portalContainerId); - wrapper = await testReact( - Hello dropdown} visible container={portalContainer}> -

    dropdown
    - - ); - return test(portalContainer); - }); -}); diff --git a/components/dropdown/__tests__/a11y-spec.tsx b/components/dropdown/__tests__/a11y-spec.tsx new file mode 100644 index 0000000000..44b0b61e62 --- /dev/null +++ b/components/dropdown/__tests__/a11y-spec.tsx @@ -0,0 +1,17 @@ +import * as React from 'react'; +import Dropdown from '../index'; +import '../style'; +import { test, createContainer, testReact } from '../../util/__tests__/a11y/validate'; + +const portalContainerId = 'a11y-portal-id'; +describe('Dropdown A11y', () => { + it('should not have any violations', async () => { + const portalContainer: HTMLElement = createContainer(portalContainerId); + await testReact( + Hello dropdown} visible container={portalContainer}> +
    dropdown
    +
    + ); + return test(portalContainer); + }); +}); diff --git a/components/dropdown/__tests__/index-spec.js b/components/dropdown/__tests__/index-spec.js deleted file mode 100644 index b526987e70..0000000000 --- a/components/dropdown/__tests__/index-spec.js +++ /dev/null @@ -1,252 +0,0 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import ReactTestUtils from 'react-dom/test-utils'; -import Enzyme, { mount } from 'enzyme'; -import Adapter from 'enzyme-adapter-react-16'; -import assert from 'power-assert'; -import Dropdown from '../index'; -import Menu from '../../menu'; -import { KEYCODE } from '../../util'; -import '../../menu/style'; - -Enzyme.configure({ adapter: new Adapter() }); - -const menu = ( - - Option 1 - Option 2 - Option 3 - Option 4 - -); - -describe('Dropdown', () => { - it('should render by defaultVisible', () => { - const initialLen = document.querySelectorAll('.next-overlay-wrapper').length; - let triggered = false; - let show = false; - const handleVisible = (visible, triggerType) => { - if (show) { - assert(!visible); - assert(triggerType === 'fromContent'); - } else { - assert(visible); - } - show = visible; - triggered = true; - }; - - const wrapper = mount( - Hello dropdown} - onVisibleChange={handleVisible} - animation={false} - triggerType="click" - > - {menu} - - ); - - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen); - - wrapper.find('.trigger').simulate('click'); - assert(triggered); - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen + 1); - triggered = false; - - const item = document.querySelector('.next-menu-item'); - ReactTestUtils.Simulate.click(item); - assert(triggered); - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen); - triggered = false; - - wrapper.unmount(); - }); - - it('should render by visible', () => { - const initialLen = document.querySelectorAll('.next-overlay-wrapper').length; - let triggered = false; - let show = false; - const handleVisible = (visible, triggerType) => { - if (show) { - assert(!visible); - assert(triggerType === 'fromContent'); - } else { - assert(visible); - } - wrapper.setProps({ - visible, - }); - show = visible; - triggered = true; - }; - - const wrapper = mount( - Hello dropdown} - onVisibleChange={handleVisible} - animation={false} - triggerType="click" - > - {menu} - - ); - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen); - - wrapper.find('.trigger').simulate('click'); - assert(triggered); - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen + 1); - triggered = false; - - const item = document.querySelector('.next-menu-item'); - ReactTestUtils.Simulate.click(item); - assert(triggered); - assert(document.querySelectorAll('.next-overlay-wrapper').length === initialLen); - triggered = false; - - wrapper.unmount(); - }); - - it('should trigger custom menu click event', () => { - let triggered = false; - const handleClick = () => { - triggered = true; - }; - - const wrapper = mount( - Hello dropdown} animation={false}> - - Option 1 - Option 2 - Option 3 - Option 4 - - - ); - - const item = document.querySelector('.next-menu-item'); - ReactTestUtils.Simulate.click(item); - assert(triggered); - }); - - // it('should only focus when triggered by keyboard', done => { - // const mountNode = document.createElement('div'); - // document.body.appendChild(mountNode); - - // ReactDOM.render( - // Hello dropdown} - // animation={false} - // > - // - // Option 1 - // Option 2 - // Option 3 - // Option 4 - // - // , - // mountNode - // ); - - // const trigger = document.querySelector('.trigger'); - - // trigger.focus(); - // trigger.click(); - - // setTimeout(() => { - // assert( - // document.activeElement !== - // document.querySelectorAll('.next-menu-item')[0] - // ); - - // ReactTestUtils.Simulate.keyDown(trigger, { - // keyCode: KEYCODE.SPACE, - // }); - - // setTimeout(() => { - // assert( - // document.activeElement === - // document.querySelectorAll('.next-menu-item')[0] - // ); - - // ReactDOM.unmountComponentAtNode(mountNode); - // document.body.removeChild(mountNode); - - // done(); - // }, 200); - // }, 200); - // }); - - it('autoFocus=false should not have any activeElement', done => { - const mountNode = document.createElement('div'); - document.body.appendChild(mountNode); - - ReactDOM.render( - Hello dropdown} animation={false}> - - Option 1 - Option 2 - Option 3 - Option 4 - - , - mountNode - ); - - const trigger = document.querySelector('.trigger'); - - trigger.focus(); - ReactTestUtils.Simulate.keyDown(document.activeElement, { - keyCode: KEYCODE.DOWN, - }); - - setTimeout(() => { - assert(document.activeElement !== document.querySelectorAll('.next-menu-item')[0]); - - ReactDOM.unmountComponentAtNode(mountNode); - document.body.removeChild(mountNode); - - done(); - }, 200); - }); - - // 官网 demo 已经不生效了,不知道为啥单测能过, Overlay v2 需要确认下 - // it('autoFocus=true should have any activeElement when triggered by keyboard', done => { - // const mountNode = document.createElement('div'); - // document.body.appendChild(mountNode); - - // ReactDOM.render( - // Hello dropdown} - // animation={false} - // > - // - // Option 1 - // Option 2 - // Option 3 - // Option 4 - // - // , - // mountNode - // ); - - // const trigger = document.querySelector('.trigger'); - - // trigger.click(); - - // setTimeout(() => { - // assert( - // document.activeElement === - // document.querySelectorAll('.next-menu-item')[0] - // ); - - // ReactDOM.unmountComponentAtNode(mountNode); - // document.body.removeChild(mountNode); - - // done(); - // }, 200); - // }); -}); diff --git a/components/dropdown/__tests__/index-spec.tsx b/components/dropdown/__tests__/index-spec.tsx new file mode 100644 index 0000000000..c9660722fc --- /dev/null +++ b/components/dropdown/__tests__/index-spec.tsx @@ -0,0 +1,114 @@ +import * as React from 'react'; +import type { MountReturn } from 'cypress/react'; +import Dropdown from '../index'; +import Menu from '../../menu'; +import '../../menu/style'; + +const menu = ( + + Option 1 + Option 2 + Option 3 + Option 4 + +); + +describe('Dropdown', () => { + it('should render by defaultVisible', () => { + const onHandleVisible = cy.spy(); + + cy.mount( + Hello dropdown} + onVisibleChange={onHandleVisible} + animation={false} + triggerType="click" + > + {menu} + + ); + + cy.get('.trigger').click(); + cy.wrap(onHandleVisible).should('be.calledOnce'); + + cy.get('.next-menu-item').first().click(); + cy.wrap(onHandleVisible).should('be.calledTwice'); + }); + + it('should render by visible', () => { + const onHandleVisible = cy.spy(); + + cy.mount( + Hello dropdown} + onVisibleChange={onHandleVisible} + animation={false} + triggerType="click" + > + {menu} + + ).as('dropdown'); + + cy.get('.trigger').click(); + cy.wrap(onHandleVisible).should('be.calledOnce'); + + cy.get('@dropdown').then(({ component, rerender }) => { + return rerender( + React.cloneElement(component as React.ReactElement, { visible: false }) + ); + }); + cy.get('.next-overlay-wrapper').should('not.exist'); + + cy.get('@dropdown').then(({ component, rerender }) => { + return rerender(React.cloneElement(component as React.ReactElement, { visible: true })); + }); + cy.get('.next-overlay-wrapper').should('exist'); + cy.get('.next-menu-item').first().click(); + cy.wrap(onHandleVisible).should('be.calledTwice').should('be.calledWith', false); + }); + + it('should trigger custom menu click event', () => { + const onClick = cy.spy(); + cy.mount( + Hello dropdown} + animation={false} + > + + Option 1 + Option 2 + Option 3 + Option 4 + + + ); + + cy.get('.next-menu-item').first().click(); + cy.wrap(onClick).should('be.calledOnce').should('be.calledWith', '0-0'); + }); + + it('autoFocus=false should not have any activeElement', () => { + cy.mount( + Hello dropdown} + animation={false} + > + + Option 1 + Option 2 + Option 3 + Option 4 + + + ); + cy.clock(); + cy.get('.trigger').focus(); + cy.get('.trigger').click(); + cy.tick(500).then(() => { + cy.wrap(document.activeElement).get('.next-menu').should('exist'); + cy.wrap(document.activeElement).should('not.have.class', 'next-menu-item'); + }); + }); +}); diff --git a/components/dropdown/dropdown.jsx b/components/dropdown/dropdown.jsx deleted file mode 100644 index f142f46702..0000000000 --- a/components/dropdown/dropdown.jsx +++ /dev/null @@ -1,188 +0,0 @@ -import React, { Component, Children } from 'react'; -import PropTypes from 'prop-types'; -import Overlay from '../overlay'; -import { func } from '../util'; - -const { noop, makeChain, bindCtx } = func; -const Popup = Overlay.Popup; - -/** - * Dropdown - * @description 继承 Popup 的 API,除非特别说明 - */ -export default class Dropdown extends Component { - static propTypes = { - prefix: PropTypes.string, - pure: PropTypes.bool, - rtl: PropTypes.bool, - className: PropTypes.string, - /** - * 弹层内容 - */ - children: PropTypes.node, - /** - * 弹层当前是否显示 - */ - visible: PropTypes.bool, - /** - * 弹层默认是否显示 - */ - defaultVisible: PropTypes.bool, - /** - * 弹层显示或隐藏时触发的回调函数 - * @param {Boolean} visible 弹层是否显示 - * @param {String} type 触发弹层显示或隐藏的来源 fromContent 表示由Dropdown内容触发; fromTrigger 表示由trigger的点击触发; docClick 表示由document的点击触发 - */ - onVisibleChange: PropTypes.func, - /** - * 触发弹层显示或者隐藏的元素 - */ - trigger: PropTypes.node, - /** - * 触发弹层显示或隐藏的操作类型,可以是 'click','hover',或者它们组成的数组,如 ['hover', 'click'] - */ - triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), - /** - * 设置此属性,弹层无法显示或隐藏 - */ - disabled: PropTypes.bool, - /** - * 弹层相对于触发元素的定位, 详见 Overlay 的定位部分 - */ - align: PropTypes.string, - /** - * 弹层相对于trigger的定位的微调, 接收数组[hoz, ver], 表示弹层在 left / top 上的增量 - * e.g. [100, 100] 表示往右(RTL 模式下是往左) 、下分布偏移100px - */ - offset: PropTypes.array, - /** - * 弹层显示或隐藏的延时时间(以毫秒为单位),在 triggerType 被设置为 hover 时生效 - */ - delay: PropTypes.number, - /** - * 弹层打开时是否让其中的元素自动获取焦点 - */ - autoFocus: PropTypes.bool, - /** - * 是否显示遮罩 - */ - hasMask: PropTypes.bool, - /** - * 开启后,children 不管是不是Menu,点击后都默认关掉弹层(2.x默认设置为true) - * @version 1.23 - */ - autoClose: PropTypes.bool, - /** - * 隐藏时是否保留子节点 - */ - cache: PropTypes.bool, - /** - * 配置动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画 - * @default { in: 'expandInDown', out: 'expandOutUp' } - */ - animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), - }; - static defaultProps = { - prefix: 'next-', - pure: false, - defaultVisible: false, - autoClose: false, - onVisibleChange: noop, - triggerType: 'hover', - disabled: false, - align: 'tl bl', - offset: [0, 0], - delay: 200, - hasMask: false, - cache: false, - onPosition: noop, - }; - - constructor(props) { - super(props); - - this.state = { - visible: 'visible' in props ? props.visible : props.defaultVisible || false, - autoFocus: 'autoFocus' in props ? props.autoFocus : false, - }; - - bindCtx(this, ['onTriggerKeyDown', 'onMenuClick', 'onVisibleChange']); - } - - static getDerivedStateFromProps(nextProps) { - const state = {}; - - if ('visible' in nextProps) { - state.visible = nextProps.visible; - } - - return state; - } - - getVisible(props = this.props) { - return 'visible' in props ? props.visible : this.state.visible; - } - - onMenuClick() { - const { autoClose } = this.props; - - if (!('visible' in this.props) && autoClose) { - this.setState({ - visible: false, - }); - } - this.onVisibleChange(false, 'fromContent'); - } - - onVisibleChange(visible, from) { - this.setState({ visible }); - - this.props.onVisibleChange(visible, from); - } - - onTriggerKeyDown() { - let autoFocus = true; - - if ('autoFocus' in this.props) { - autoFocus = this.props.autoFocus; - } - - this.setState({ - autoFocus, - }); - } - - render() { - const { trigger, rtl, autoClose } = this.props; - - const child = Children.only(this.props.children); - let content = child; - if (typeof child.type === 'function' && child.type.isNextMenu) { - content = React.cloneElement(child, { - onItemClick: makeChain(this.onMenuClick, child.props.onItemClick), - }); - } else if (autoClose) { - content = React.cloneElement(child, { - onClick: makeChain(this.onMenuClick, child.props.onClick), - }); - } - - const newTrigger = React.cloneElement(trigger, { - onKeyDown: makeChain(this.onTriggerKeyDown, trigger.props.onKeyDown), - }); - - return ( - - {content} - - ); - } -} diff --git a/components/dropdown/dropdown.tsx b/components/dropdown/dropdown.tsx new file mode 100644 index 0000000000..363fe20ca3 --- /dev/null +++ b/components/dropdown/dropdown.tsx @@ -0,0 +1,139 @@ +import React, { Component, Children } from 'react'; +import * as PropTypes from 'prop-types'; +import Overlay from '../overlay'; +import { func } from '../util'; +import type { DropdownProps, DropdownState } from './types'; + +const { noop, makeChain, bindCtx } = func; +const Popup = Overlay.Popup; + +export default class Dropdown extends Component { + static propTypes = { + prefix: PropTypes.string, + pure: PropTypes.bool, + rtl: PropTypes.bool, + className: PropTypes.string, + children: PropTypes.node, + visible: PropTypes.bool, + defaultVisible: PropTypes.bool, + onVisibleChange: PropTypes.func, + trigger: PropTypes.node, + triggerType: PropTypes.oneOfType([PropTypes.string, PropTypes.array]), + disabled: PropTypes.bool, + align: PropTypes.string, + offset: PropTypes.array, + delay: PropTypes.number, + autoFocus: PropTypes.bool, + hasMask: PropTypes.bool, + autoClose: PropTypes.bool, + cache: PropTypes.bool, + animation: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]), + }; + static defaultProps = { + prefix: 'next-', + pure: false, + defaultVisible: false, + autoClose: false, + onVisibleChange: noop, + triggerType: 'hover', + disabled: false, + align: 'tl bl', + offset: [0, 0], + delay: 200, + hasMask: false, + cache: false, + onPosition: noop, + }; + static displayName = 'Dropdown'; + + constructor(props: DropdownProps) { + super(props); + + this.state = { + visible: 'visible' in props ? props.visible : props.defaultVisible || false, + autoFocus: 'autoFocus' in props ? props.autoFocus : false, + }; + + bindCtx(this, ['onTriggerKeyDown', 'onMenuClick', 'onVisibleChange']); + } + + static getDerivedStateFromProps(nextProps: DropdownProps) { + const state: Partial = {}; + + if ('visible' in nextProps) { + state.visible = nextProps.visible; + } + + return state; + } + + getVisible(props = this.props) { + return 'visible' in props ? props.visible : this.state.visible; + } + + onMenuClick() { + const { autoClose } = this.props; + + if (!('visible' in this.props) && autoClose) { + this.setState({ + visible: false, + }); + } + this.onVisibleChange(false, 'fromContent'); + } + + onVisibleChange(visible: boolean, from: string) { + this.setState({ visible }); + + this.props.onVisibleChange!(visible, from); + } + + onTriggerKeyDown() { + let autoFocus: boolean | undefined = true; + + if ('autoFocus' in this.props) { + autoFocus = this.props.autoFocus; + } + + this.setState({ + autoFocus, + }); + } + + render() { + const { rtl, autoClose, trigger } = this.props; + + const child = Children.only(this.props.children); + let content = child; + if ( + typeof child.type === 'function' && + (child.type as typeof child.type & { isNextMenu: boolean }).isNextMenu + ) { + content = React.cloneElement(child, { + onItemClick: makeChain(this.onMenuClick, child.props.onItemClick), + }); + } else if (autoClose) { + content = React.cloneElement(child, { + onClick: makeChain(this.onMenuClick, child.props.onClick), + }); + } + + const newTrigger = React.cloneElement(trigger!, { + onKeyDown: makeChain(this.onTriggerKeyDown, trigger!.props.onKeyDown), + }); + + return ( + + {content} + + ); + } +} diff --git a/components/dropdown/index.d.ts b/components/dropdown/index.d.ts deleted file mode 100644 index 7eb52cb97a..0000000000 --- a/components/dropdown/index.d.ts +++ /dev/null @@ -1,198 +0,0 @@ -/// - -import React from 'react'; -import { CommonProps } from '../util'; - -export interface DropdownProps extends React.HTMLAttributes, CommonProps { - /** - * 弹层内容 - */ - children?: React.ReactNode; - - /** - * 弹层当前是否显示 - */ - visible?: boolean; - - /** - * 弹层请求关闭时触发事件的回调函数 - */ - onRequestClose?: (type: string, e: {}) => void; - - /** - * 弹层定位的参照元素 - */ - target?: any; - - /** - * 弹层相对于触发元素的定位, 详见 Overlay 的定位部分 - */ - align?: string; - - /** - * 弹层相对于触发元素定位的微调 - */ - offset?: Array; - - /** - * 渲染组件的容器,如果是函数需要返回 ref,如果是字符串则是该 DOM 的 id,也可以直接传入 DOM 节点 - */ - container?: any; - - /** - * 是否显示遮罩 - */ - hasMask?: boolean; - - /** - * 是否支持 esc 按键关闭弹层 - */ - canCloseByEsc?: boolean; - - /** - * 点击弹层外的区域是否关闭弹层,不显示遮罩时生效 - */ - canCloseByOutSideClick?: boolean; - - /** - * 点击遮罩区域是否关闭弹层,显示遮罩时生效 - */ - canCloseByMask?: boolean; - - /** - * 弹层打开前触发事件的回调函数 - */ - beforeOpen?: () => void; - - /** - * 弹层打开时触发事件的回调函数 - */ - onOpen?: () => void; - - /** - * 弹层打开后触发事件的回调函数, 如果有动画,则在动画结束后触发 - */ - afterOpen?: () => void; - - /** - * 弹层关闭前触发事件的回调函数 - */ - beforeClose?: () => void; - - /** - * 弹层关闭时触发事件的回调函数 - */ - onClose?: () => void; - - /** - * 弹层关闭后触发事件的回调函数, 如果有动画,则在动画结束后触发 - */ - afterClose?: () => void; - - /** - * 弹层定位完成前触发的事件 - */ - beforePosition?: () => void; - - /** - * 弹层定位完成时触发的事件 - */ - onPosition?: (config: {}, node: {}) => void; - - /** - * 是否在每次弹层重新渲染后强制更新定位信息,一般用于弹层内容区域大小发生变化时,仍需保持原来的定位方式 - */ - shouldUpdatePosition?: boolean; - - /** - * 弹层打开时是否让其中的元素自动获取焦点 - */ - autoFocus?: boolean; - - /** - * 当弹层由于页面滚动等情况不在可视区域时,是否自动调整定位以出现在可视区域 - */ - needAdjust?: boolean; - - /** - * 是否禁用页面滚动 - */ - disableScroll?: boolean; - - /** - * 隐藏时是否保留子节点 - */ - cache?: boolean; - - /** - * 安全节点,当点击 document 的时候,如果包含该节点则不会关闭弹层,如果是函数需要返回 ref,如果是字符串则是该 DOM 的 id,也可以直接传入 DOM 节点,或者以上值组成的数组 - */ - safeNode?: any; - - /** - * 弹层的根节点的样式类 - */ - wrapperClassName?: string; - - /** - * 弹层的根节点的内联样式 - */ - wrapperStyle?: React.CSSProperties; - - /** - * 配置动画的播放方式,支持 { in: 'enter-class', out: 'leave-class' } 的对象参数,如果设置为 false,则不播放动画 - */ - animation?: any | boolean; - - /** - * 触发弹层显示或者隐藏的元素 - */ - trigger?: React.ReactNode; - - /** - * 触发弹层显示或隐藏的操作类型,可以是 'click','hover',或者它们组成的数组,如 ['hover', 'click'] - */ - triggerType?: string | Array; - - /** - * 当 triggerType 为 click 时才生效,可自定义触发弹层显示的键盘码 - */ - triggerClickKeycode?: number | Array; - - /** - * 弹层默认是否显示 - */ - defaultVisible?: boolean; - - /** - * 开启后,默认点击children弹窗就收起 0.x 2.x中默认是true - */ - autoClose?: boolean; - - /** - * 弹层显示或隐藏时触发的回调函数 - */ - onVisibleChange?: (visible: boolean, type: string, e: {}) => void; - - /** - * 设置此属性,弹层无法显示或隐藏 - */ - disabled?: boolean; - - /** - * 弹层显示或隐藏的延时时间(以毫秒为单位),在 triggerType 被设置为 hover 时生效 - */ - delay?: number; - - /** - * trigger 是否可以关闭弹层 - */ - canCloseByTrigger?: boolean; - - /** - * 是否跟随trigger滚动 - */ - followTrigger?: boolean; -} - -export default class Dropdown extends React.Component {} diff --git a/components/dropdown/index.jsx b/components/dropdown/index.jsx deleted file mode 100644 index e1866b5fce..0000000000 --- a/components/dropdown/index.jsx +++ /dev/null @@ -1,16 +0,0 @@ -import ConfigProvider from '../config-provider'; -import Dropdown from './dropdown'; - -export default ConfigProvider.config(Dropdown, { - transform: /* istanbul ignore next */ (props, deprecated) => { - if ('triggerType' in props) { - const triggerType = Array.isArray(props.triggerType) ? [...props.triggerType] : [props.triggerType]; - - if (triggerType.indexOf('focus') > -1) { - deprecated('triggerType[focus]', 'triggerType[hover, click]', 'Balloon'); - } - } - - return props; - }, -}); diff --git a/components/dropdown/index.tsx b/components/dropdown/index.tsx new file mode 100644 index 0000000000..aafa564921 --- /dev/null +++ b/components/dropdown/index.tsx @@ -0,0 +1,20 @@ +import ConfigProvider from '../config-provider'; +import Dropdown from './dropdown'; +import type { DropdownProps } from './types'; + +export type { DropdownProps }; +export default ConfigProvider.config(Dropdown, { + transform: (props, deprecated) => { + if ('triggerType' in props) { + const triggerType = Array.isArray(props.triggerType) + ? [...props.triggerType] + : [props.triggerType]; + + if (triggerType.indexOf('focus') > -1) { + deprecated('triggerType[focus]', 'triggerType[hover, click]', 'Balloon'); + } + } + + return props; + }, +}); diff --git a/components/dropdown/mobile/index.jsx b/components/dropdown/mobile/index.jsx deleted file mode 100644 index 738e983381..0000000000 --- a/components/dropdown/mobile/index.jsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Dropdown as MeetDropdown } from '@alifd/meet-react'; -import NextDropdown from '../index'; - -const Dropdown = MeetDropdown ? MeetDropdown : NextDropdown; - -export default Dropdown; diff --git a/components/dropdown/mobile/index.tsx b/components/dropdown/mobile/index.tsx new file mode 100644 index 0000000000..977b8510e9 --- /dev/null +++ b/components/dropdown/mobile/index.tsx @@ -0,0 +1,7 @@ +// @ts-expect-error meet-react does not export Dropdown +import { Dropdown as MeetDropdown } from '@alifd/meet-react'; +import NextDropdown from '../index'; + +const Dropdown = MeetDropdown ? MeetDropdown : NextDropdown; + +export default Dropdown; diff --git a/components/dropdown/style.js b/components/dropdown/style.ts similarity index 100% rename from components/dropdown/style.js rename to components/dropdown/style.ts diff --git a/components/dropdown/types.ts b/components/dropdown/types.ts new file mode 100644 index 0000000000..b3322664d4 --- /dev/null +++ b/components/dropdown/types.ts @@ -0,0 +1,126 @@ +import type * as React from 'react'; +import type { PopupProps } from '../overlay'; +/** + * @api Dropdown + * 继承 Popup 绝大多数属性,除了 canCloseByOutSideClick, autoFocus,以下列举为常用属性,其他可参考 Overlay 文档 + * @en Inherit most properties from Popup, except canCloseByOutSideClick, autoFocus, the following are common properties, other properties can refer to Overlay documentation + */ +export interface DropdownProps extends Omit { + /** + * 开启后,children 不管是不是 Menu,点击后都默认关掉弹层(2.x 默认设置为 true) + * @en If set true, the popup will be closed when the child is clicked no matter whether it is a Menu (2.x default is true) + * @defaultValue false + */ + autoClose?: boolean; + + /** + * 弹层内容 + * @en Content in Dropdown + */ + children: React.ReactElement; + + /** + * 弹层当前是否显示 + * @en Overlay display or not now + */ + visible?: boolean; + + /** + * 弹层相对于触发元素的定位,详见 Overlay 的定位部分 + * @en Overlay position relative to trigger element, see details Overlay align + * @defaultValue 'tl bl' + */ + align?: string; + + /** + * 弹层相对于触发元素定位的微调 + * @en Extra adjustment for trigger element. + * @remarks + * 接收数组 [hoz, ver], 表示弹层在 left / top 上的增量。e.g. [100, 100] 表示往右 (RTL 模式下是往左) 、下分布偏移 100px + * - + * receive array [hoz, ver], indicate the offset of the pop-up layer on the left / top. e.g. [100, 100] means to the right (in RTL mode, it means to the left) and downward offset 100px + * @defaultValue [0, 0] + */ + offset?: [number, number]; + + /** + * 是否显示遮罩 + * @en Display mask or not + * @defaultValue false + */ + hasMask?: boolean; + + /** + * 配置动画的播放方式,支持 \{in: 'enter-class', out: 'leave-class' \} 的对象参数,如果设置为 false,则不播放动画 + * @en Animation play mode, support object value: \{ in: 'enter-class', out: 'leave-class' \}, there is no animation if set false + * @defaultValue \{ in: 'expandInDown', out: 'expandOutUp' \} + */ + animation?: string | false | Record<'in' | 'out', string>; + + /** + * 触发弹层显示或者隐藏的元素 + * @en Trigger element + */ + trigger: React.ReactElement; + + /** + * 触发弹层显示或隐藏的操作类型,可以是 'click','hover',或者它们组成的数组,如 ['hover', 'click'] + * @en Operation type of trigger overlay toggle visible, eg 'hover', 'click' + * @defaultValue 'hover' + */ + triggerType?: PopupProps['triggerType']; + + /** + * 当 triggerType 为 click 时才生效,可自定义触发弹层显示的键盘码 + * @skip + */ + triggerClickKeycode?: PopupProps['triggerClickKeycode']; + + /** + * 弹层默认是否显示 + * @en Overlay display or not in default situation + * @defaultValue false + */ + defaultVisible?: boolean; + + /** + * 弹层显示或隐藏时触发的回调函数 + * @en Callback function when toggle visible of overlay + */ + onVisibleChange?: PopupProps['onVisibleChange']; + + /** + * 设置此属性,弹层无法显示或隐藏 + * @en Overlay can not toggle visible if you set disabled attribute + * @defaultValue false + */ + disabled?: PopupProps['disabled']; + + /** + * 弹层显示或隐藏的延时时间(以毫秒为单位),在 triggerType 被设置为 hover 时生效 + * @en Delay time of toggle overlay visible(unit: ms),if triggerType value is 'hover', delay time will work + * @defaultValue 200 + */ + delay?: PopupProps['delay']; + + /** + * 隐藏时是否保留子节点 + * @en Whether to keep dom nodes when hidden + * @defaultValue false + * @skip + */ + cache?: boolean; + + /** + * 弹层打开时是否让其中的元素自动获取焦点,仅在初始化时有效 + * @en Whether to focus the element in the overlay automatically when the overlay is opened, only valid at initialization + * @defaultValue false + * @skip + */ + autoFocus?: boolean; +} + +export interface DropdownState { + visible: boolean | undefined; + autoFocus: boolean | undefined; +} diff --git a/components/field/__docs__/demo/custom/index.tsx b/components/field/__docs__/demo/custom/index.tsx index b9f433ab4f..05ed7453b2 100644 --- a/components/field/__docs__/demo/custom/index.tsx +++ b/components/field/__docs__/demo/custom/index.tsx @@ -2,38 +2,23 @@ import React from 'react'; import ReactDOM from 'react-dom'; import { Button, Field } from '@alifd/next'; -class Custom extends React.Component { - constructor(props) { - super(props); - - this.state = { - value: typeof props.value === 'undefined' ? [] : props.value, - }; - } - - // update value - componentWillReceiveProps(nextProps) { - if ('value' in nextProps) { - this.setState({ - value: typeof nextProps.value === 'undefined' ? [] : nextProps.value, - }); - } - } +interface CustomProps { + value?: string[]; + onChange: (value: string[]) => void; +} +class Custom extends React.Component { onAdd = () => { - const value = this.state.value.concat([]); - value.push('new'); - - this.setState({ - value, - }); - this.props.onChange(value); + const { value = [] } = this.props; + const newValue = value.concat('new'); + this.props.onChange(newValue); }; render() { + const { value = [] } = this.props; return (
    - {this.state.value.map((v, i) => { + {value.map((v, i) => { return ; })}