From 282b7c8575bdb18b8ea696c93af2f0c266736d2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Tue, 31 Aug 2021 15:51:02 +0800 Subject: [PATCH] feat: Cascader support multiple mode (#31936) * chore: Update cascader version * chore: replace cascader * chore: default allowClear * chore: Update docs * test: Back of part * test: More snapshot * test: more and more * test: Failed of defaultValue * test: all basic test case * test: All snapshot * chore: Update cascader * chore: disable react/jsx-no-bind * chore: fix lint * chore: fix less order * chore: fix deps * docs: Update multiple demo * docs: Add multiple example * test: Update snapshot * test: update snapshot --- .eslintrc.js | 1 + .../__tests__/__snapshots__/demo.test.js.snap | 1533 +++++++----- .../__snapshots__/index.test.js.snap | 2223 ++++++++++------- components/cascader/__tests__/index.test.js | 317 +-- components/cascader/demo/multiple.md | 67 + components/cascader/index.en-US.md | 4 +- components/cascader/index.tsx | 849 ++----- components/cascader/index.zh-CN.md | 4 +- components/cascader/style/index.less | 275 +- components/cascader/style/index.tsx | 2 +- components/cascader/style/rtl.less | 100 +- .../__snapshots__/components.test.js.snap | 468 ++-- .../__tests__/__snapshots__/demo.test.js.snap | 78 +- .../__tests__/__snapshots__/demo.test.js.snap | 291 ++- .../__tests__/__snapshots__/demo.test.js.snap | 84 +- .../__tests__/__snapshots__/demo.test.js.snap | 293 ++- package.json | 2 +- tests/shared/demoTest.ts | 5 + tests/shared/excludeWarning.tsx | 27 + 19 files changed, 3568 insertions(+), 3055 deletions(-) create mode 100644 components/cascader/demo/multiple.md create mode 100644 tests/shared/excludeWarning.tsx diff --git a/.eslintrc.js b/.eslintrc.js index a1906d4a13..fd65d5cc67 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -94,6 +94,7 @@ module.exports = { 'react/sort-comp': 0, 'react/display-name': 0, 'react/static-property-placement': 0, + 'react/jsx-no-bind': 0, // Should not check test file 'react/no-find-dom-node': 0, 'react/no-unused-prop-types': 0, 'react/default-props-match-prop-types': 0, diff --git a/components/cascader/__tests__/__snapshots__/demo.test.js.snap b/components/cascader/__tests__/__snapshots__/demo.test.js.snap index 14c593e25d..13cf68590d 100644 --- a/components/cascader/__tests__/__snapshots__/demo.test.js.snap +++ b/components/cascader/__tests__/__snapshots__/demo.test.js.snap @@ -1,193 +1,284 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders ./components/cascader/demo/basic.md correctly 1`] = ` - - - - - + + + Please select + + + - + `; exports[`renders ./components/cascader/demo/change-on-select.md correctly 1`] = ` - - - - - + + + + - + `; exports[`renders ./components/cascader/demo/custom-dropdown.md correctly 1`] = ` - - - - - + + + Please select + + + - + `; exports[`renders ./components/cascader/demo/custom-render.md correctly 1`] = ` - - - - Zhejiang / - - - Hangzhou / - - - West Lake ( - - 752100 - - ) - - - - - + + + + Zhejiang / + + + Hangzhou / + + + West Lake ( + + 752100 + + ) + + + + - + `; exports[`renders ./components/cascader/demo/custom-trigger.md correctly 1`] = ` @@ -195,7 +286,6 @@ exports[`renders ./components/cascader/demo/custom-trigger.md correctly 1`] = ` Unselect  Change city @@ -203,374 +293,613 @@ exports[`renders ./components/cascader/demo/custom-trigger.md correctly 1`] = ` `; exports[`renders ./components/cascader/demo/default-value.md correctly 1`] = ` - - - Zhejiang / Hangzhou / West Lake - - - - + + + Zhejiang / Hangzhou / West Lake + + + - + `; exports[`renders ./components/cascader/demo/disabled-option.md correctly 1`] = ` - - - - - + + + + - + `; exports[`renders ./components/cascader/demo/fields-name.md correctly 1`] = ` - - - - - + + + Please select + + + - + `; exports[`renders ./components/cascader/demo/hover.md correctly 1`] = ` - - - - - + + + + - + `; exports[`renders ./components/cascader/demo/lazy.md correctly 1`] = ` - - - - - + + + + - + +`; + +exports[`renders ./components/cascader/demo/multiple.md correctly 1`] = ` +
+
+
+
+ +
+
+ +
+
`; exports[`renders ./components/cascader/demo/search.md correctly 1`] = ` - - - - - + + + Please select + + + - + `; exports[`renders ./components/cascader/demo/size.md correctly 1`] = ` Array [ - - - - - + + + + - , + ,
,
, - - - - - + + + + - , + ,
,
, - - - - - + + + + - , + ,
,
, ] @@ -578,141 +907,225 @@ Array [ exports[`renders ./components/cascader/demo/suffix.md correctly 1`] = ` Array [ - - - - - + + + Please select + + + - , + ,
,
, - +
+ + + + + Please select + +
- - - , + ,
,
, - - - - - + + + Please select + + + - , + ,
,
, - - - - - + + + Please select + + + - , + , ] `; diff --git a/components/cascader/__tests__/__snapshots__/index.test.js.snap b/components/cascader/__tests__/__snapshots__/index.test.js.snap index 91b22f9010..4385c69659 100644 --- a/components/cascader/__tests__/__snapshots__/index.test.js.snap +++ b/components/cascader/__tests__/__snapshots__/index.test.js.snap @@ -1,530 +1,129 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Cascader can be selected 1`] = ` -
-
-
+
+
+
-
    - -
-
-
-
-`; - -exports[`Cascader can be selected 2`] = ` -
-
-
-
    - - -
-
    - -
-
    - -
-
-
-
-`; - -exports[`Cascader can be selected 3`] = `
`; - -exports[`Cascader can be selected in RTL direction 1`] = ` -
-
-
-
    - - -
-
    - -
-
-
-
-`; - -exports[`Cascader can be selected in RTL direction 2`] = ` -
-
-
-
    - - -
-
    - -
-
    - -
-
-
-
-`; - -exports[`Cascader have a notFoundContent that fit trigger input width 1`] = ` -
-
-
-
    - +
  • +
    + Jiangsu +
    +
    + - No Data -
    + + +
+ + + @@ -533,25 +132,32 @@ exports[`Cascader have a notFoundContent that fit trigger input width 1`] = `
`; -exports[`Cascader popup correctly when panel is hidden 1`] = `
`; - -exports[`Cascader popup correctly when panel is open 1`] = ` -
-
-
+exports[`Cascader can be selected 2`] = ` +
+
+
+
- +
+
- +
+ + + + +
+
+
+`; + +exports[`Cascader can be selected 3`] = ` +
+
+
+ + + +
+
+
+`; + +exports[`Cascader can be selected in RTL direction 1`] = ` +
+
+
+ + +
+
+
+`; + +exports[`Cascader can be selected in RTL direction 2`] = ` +
+
+
+ + + +
+
+
+`; + +exports[`Cascader can be selected in RTL direction 3`] = ` +
+
+
+ + +
@@ -612,22 +865,31 @@ exports[`Cascader popup correctly when panel is open 1`] = ` `; exports[`Cascader popup correctly with defaultValue 1`] = ` -
-
-
+
+
+
+
- +
+
- +
+
- +
@@ -733,195 +1015,356 @@ exports[`Cascader popup correctly with defaultValue 1`] = ` `; exports[`Cascader popup correctly with defaultValue RTL 1`] = ` -
+
-
-
    -
+
+
+
+
- Zhejiang - - - - - - - +
  • +
    + Jiangsu +
    +
    + + + +
    +
  • + + -
      - +
    + -
      - -
    + West Lake +
    + + +
    +
    + +
    `; exports[`Cascader rtl render component should be rendered correctly in RTL direction 1`] = ` - - - - - + + +
    + - +
    `; exports[`Cascader should highlight keyword and filter when search in Cascader 1`] = ` -
    -
    -
    +
    +
    +
    @@ -930,41 +1373,63 @@ exports[`Cascader should highlight keyword and filter when search in Cascader 1` `; exports[`Cascader should highlight keyword and filter when search in Cascader with same field name of label and value 1`] = ` -
    -
    -
    +
    +
    +
    @@ -973,65 +1438,72 @@ exports[`Cascader should highlight keyword and filter when search in Cascader wi `; exports[`Cascader should render not found content 1`] = ` -
    -
    -
    +
    +
    +
    @@ -1042,65 +1514,72 @@ exports[`Cascader should render not found content 1`] = ` `; exports[`Cascader should show not found content when options.length is 0 1`] = ` -
    -
    -
    +
    +
    +
    @@ -1111,62 +1590,90 @@ exports[`Cascader should show not found content when options.length is 0 1`] = ` `; exports[`Cascader support controlled mode 1`] = ` - - - Zhejiang / Hangzhou / West Lake - - - - + + + Zhejiang / Hangzhou / West Lake + +
    + - +
    `; diff --git a/components/cascader/__tests__/index.test.js b/components/cascader/__tests__/index.test.js index ee28f613d9..c4151463f5 100644 --- a/components/cascader/__tests__/index.test.js +++ b/components/cascader/__tests__/index.test.js @@ -1,12 +1,31 @@ import React from 'react'; -import { render, mount } from 'enzyme'; +import { mount } from 'enzyme'; import KeyCode from 'rc-util/lib/KeyCode'; import Cascader from '..'; import ConfigProvider from '../../config-provider'; +import excludeAllWarning from '../../../tests/shared/excludeWarning'; import focusTest from '../../../tests/shared/focusTest'; import mountTest from '../../../tests/shared/mountTest'; import rtlTest from '../../../tests/shared/rtlTest'; -import { sleep } from '../../../tests/utils'; + +function toggleOpen(wrapper) { + wrapper.find('.ant-select-selector').simulate('mousedown'); +} + +function isOpen(wrapper) { + return !!wrapper.find('Trigger').props().popupVisible; +} + +function getDropdown(wrapper) { + return wrapper.find('.ant-select-dropdown'); +} + +function clickOption(wrapper, menuIndex, itemIndex, type = 'click') { + const menu = wrapper.find('ul.ant-cascader-menu').at(menuIndex); + const itemList = menu.find('li.ant-cascader-menu-item'); + + itemList.at(itemIndex).simulate(type); +} const options = [ { @@ -48,13 +67,15 @@ function filter(inputValue, path) { } describe('Cascader', () => { - focusTest(Cascader); + excludeAllWarning(); + + focusTest(Cascader, { refFocus: true }); mountTest(Cascader); rtlTest(Cascader); it('popup correctly when panel is hidden', () => { const wrapper = mount(); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); + expect(isOpen(wrapper)).toBeFalsy(); }); it('popup correctly when panel is open', () => { @@ -62,8 +83,8 @@ describe('Cascader', () => { const wrapper = mount( , ); - wrapper.find('input').simulate('click'); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); + toggleOpen(wrapper); + expect(isOpen(wrapper)).toBeTruthy(); expect(onPopupVisibleChange).toHaveBeenCalledWith(true); }); @@ -77,83 +98,71 @@ describe('Cascader', () => { it('popup correctly with defaultValue', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); + toggleOpen(wrapper); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); }); it('should support popupVisible', () => { const wrapper = mount(); - expect(wrapper.find('Trigger').instance().getComponent().props.visible).toBe(false); + expect(isOpen(wrapper)).toBeFalsy(); wrapper.setProps({ popupVisible: true }); - expect(wrapper.find('Trigger').instance().getComponent().props.visible).toBe(true); + expect(isOpen(wrapper)).toBeTruthy(); }); it('can be selected', () => { const onChange = jest.fn(); const wrapper = mount(); - wrapper.find('input').simulate('click'); - let popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(0) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); - popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(1) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); - popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(2) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); - expect(render(wrapper.find('Trigger').instance().getComponent())).toMatchSnapshot(); + + toggleOpen(wrapper); + expect(isOpen(wrapper)).toBeTruthy(); + + clickOption(wrapper, 0, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + clickOption(wrapper, 1, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + clickOption(wrapper, 2, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(['zhejiang', 'hangzhou', 'xihu'], expect.anything()); }); it('backspace should work with `Cascader[showSearch]`', () => { const wrapper = mount(); wrapper.find('input').simulate('change', { target: { value: '123' } }); - expect(wrapper.state('inputValue')).toBe('123'); - wrapper.find('input').simulate('keydown', { keyCode: KeyCode.BACKSPACE }); - // Simulate onKeyDown will not trigger onChange by default, so the value is still '123' - expect(wrapper.state('inputValue')).toBe('123'); + expect(isOpen(wrapper)).toBeTruthy(); + + wrapper.find('input').simulate('keydown', { which: KeyCode.BACKSPACE }); + expect(isOpen(wrapper)).toBeTruthy(); + + wrapper.find('input').simulate('change', { target: { value: '' } }); + expect(isOpen(wrapper)).toBeTruthy(); + + wrapper.find('input').simulate('keydown', { which: KeyCode.BACKSPACE }); + expect(isOpen(wrapper)).toBeFalsy(); }); it('should highlight keyword and filter when search in Cascader', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'z' } }); - expect(wrapper.state('inputValue')).toBe('z'); - const popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - expect(popupWrapper.render()).toMatchSnapshot(); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); }); it('should highlight keyword and filter when search in Cascader with same field name of label and value', () => { const customOptions = [ { name: 'Zhejiang', - value: 'Zhejiang', children: [ { name: 'Hangzhou', - value: 'Hangzhou', children: [ { name: 'West Lake', - value: 'West Lake', }, { name: 'Xia Sha', - value: 'Xia Sha', disabled: true, }, ], @@ -171,81 +180,35 @@ describe('Cascader', () => { showSearch={{ filter: customFilter }} />, ); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'z' } }); - expect(wrapper.state('inputValue')).toBe('z'); - const popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - expect(popupWrapper.render()).toMatchSnapshot(); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); }); it('should render not found content', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: '__notfoundkeyword__' } }); - expect(wrapper.state('inputValue')).toBe('__notfoundkeyword__'); - const popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - expect(popupWrapper.render()).toMatchSnapshot(); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); }); - it('should support to clear selection', async () => { + it('should support to clear selection', () => { const wrapper = mount(); - const willUnmount = jest.spyOn(wrapper.instance(), 'componentWillUnmount'); - const clearTimeoutSpy = jest.spyOn(global, 'clearTimeout'); - expect(wrapper.find('.ant-cascader-picker-label').text()).toBe('Zhejiang / Hangzhou'); - wrapper.find('.ant-cascader-picker-clear').at(0).simulate('click'); - await sleep(300); - expect(wrapper.find('.ant-cascader-picker-label').text()).toBe(''); - wrapper.unmount(); - expect(willUnmount).toHaveBeenCalled(); - expect(clearTimeoutSpy).toHaveBeenCalled(); - clearTimeoutSpy.mockRestore(); - }); - - it('should close popup when clear selection', () => { - const onPopupVisibleChange = jest.fn(); - const wrapper = mount( - , - ); - wrapper.find('.ant-cascader-picker-clear').at(0).simulate('click'); - expect(onPopupVisibleChange).toHaveBeenCalledWith(false); + expect(wrapper.find('.ant-select-selection-item').text()).toEqual('Zhejiang / Hangzhou'); + wrapper.find('.ant-select-clear').at(0).simulate('mouseDown'); + expect(wrapper.exists('.ant-select-selection-item')).toBeFalsy(); }); it('should clear search input when clear selection', () => { const wrapper = mount( , ); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'xxx' } }); - expect(wrapper.state('inputValue')).toBe('xxx'); - wrapper.find('.ant-cascader-picker-clear').at(0).simulate('click'); - expect(wrapper.state('inputValue')).toBe(''); - }); - it('should not trigger visible change when click search input', () => { - const onPopupVisibleChange = jest.fn(); - const wrapper = mount( - , - ); - wrapper.find('input').simulate('focus'); - expect(onPopupVisibleChange).toHaveBeenCalledTimes(0); - wrapper.find('input').simulate('click'); - expect(onPopupVisibleChange).toHaveBeenCalledTimes(1); - wrapper.find('input').simulate('click'); - expect(onPopupVisibleChange).toHaveBeenCalledTimes(1); - wrapper.find('input').simulate('blur'); - wrapper.setState({ popupVisible: false }); - wrapper.find('input').simulate('click'); - expect(onPopupVisibleChange).toHaveBeenCalledTimes(2); + wrapper.find('.ant-select-clear').at(0).simulate('mouseDown'); + expect(wrapper.find('input').props().value).toEqual(''); }); it('should change filtered item when options are changed', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'a' } }); expect(wrapper.find('.ant-cascader-menu-item').length).toBe(2); wrapper.setProps({ options: [options[0]] }); @@ -254,12 +217,12 @@ describe('Cascader', () => { it('should select item immediately when searching and pressing down arrow key', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'a' } }); expect(wrapper.find('.ant-cascader-menu-item').length).toBe(2); expect(wrapper.find('.ant-cascader-menu-item-active').length).toBe(0); + wrapper.find('input').simulate('keyDown', { - keyCode: KeyCode.DOWN, + which: KeyCode.DOWN, }); expect(wrapper.find('.ant-cascader-menu-item-active').length).toBe(1); }); @@ -299,31 +262,40 @@ describe('Cascader', () => { ], }, ]; + + const onChange = jest.fn(); + const wrapper = mount( , ); - wrapper.instance().handleChange(['zhejiang', 'hangzhou', 'xihu'], customerOptions); - expect(wrapper.find('.ant-cascader-picker-label').text().split('/').length).toBe(3); + + clickOption(wrapper, 0, 0); + clickOption(wrapper, 1, 0); + clickOption(wrapper, 2, 0); + expect(wrapper.find('.ant-select-selection-item').text()).toEqual( + 'Zhejiang / Hangzhou / West Lake', + ); + expect(onChange).toHaveBeenCalledWith(['zhejiang', 'hangzhou', 'xihu'], expect.anything()); }); it('should show not found content when options.length is 0', () => { const customerOptions = []; const wrapper = mount(); - wrapper.find('input').simulate('click'); - const popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - expect(popupWrapper.render()).toMatchSnapshot(); + toggleOpen(wrapper); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); }); - it('not found content shoule be disabled', () => { - const wrapper = mount(); - wrapper.find('input').simulate('click'); + it('not found content should be disabled', () => { + const wrapper = mount(); expect(wrapper.find('.ant-cascader-menu-item-disabled').length).toBe(1); }); @@ -336,37 +308,33 @@ describe('Cascader', () => { it('limit with positive number', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'a' } }); - expect(wrapper.find('.ant-cascader-menu-item').length).toBe(1); + expect(wrapper.find('.ant-cascader-menu-item')).toHaveLength(1); }); it('not limit', () => { const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'a' } }); - expect(wrapper.find('.ant-cascader-menu-item').length).toBe(2); + expect(wrapper.find('.ant-cascader-menu-item')).toHaveLength(2); }); it('negative limit', () => { const wrapper = mount(); wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'a' } }); - expect(wrapper.find('.ant-cascader-menu-item').length).toBe(2); - expect(errorSpy).toHaveBeenCalledWith( - "Warning: [antd: Cascader] 'limit' of showSearch should be positive number or false.", - ); + expect(wrapper.find('.ant-cascader-menu-item')).toHaveLength(2); }); }); - it('should warning if not find `value` in `options`', () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - mount(); - expect(errorSpy).toHaveBeenCalledWith( - 'Warning: [antd: Cascader] Not found `value` in `options`.', - ); - errorSpy.mockRestore(); - }); + // FIXME: Move to `rc-tree-select` instead + // it('should warning if not find `value` in `options`', () => { + // const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + // mount(); + // expect(errorSpy).toHaveBeenCalledWith( + // 'Warning: [antd: Cascader] Not found `value` in `options`.', + // ); + // errorSpy.mockRestore(); + // }); // https://github.com/ant-design/ant-design/issues/17690 it('should not breaks when children is null', () => { @@ -388,40 +356,24 @@ describe('Cascader', () => { }).not.toThrow(); }); - // https://github.com/ant-design/ant-design/issues/18176 - it('have a notFoundContent that fit trigger input width', () => { - const wrapper = mount( - , - ); - const popupWrapper = mount(wrapper.find('Trigger').instance().getComponent()); - expect(popupWrapper.render()).toMatchSnapshot(); - }); - it('placeholder works correctly', () => { const wrapper = mount(); - expect(wrapper.find('input').prop('placeholder')).toBe('Please select'); + expect(wrapper.find('.ant-select-selection-placeholder').text()).toEqual(''); const customPlaceholder = 'Custom placeholder'; wrapper.setProps({ placeholder: customPlaceholder, }); - expect(wrapper.find('input').prop('placeholder')).toBe(customPlaceholder); + expect(wrapper.find('.ant-select-selection-placeholder').text()).toEqual(customPlaceholder); }); it('popup correctly with defaultValue RTL', () => { const wrapper = mount( - + , ); - wrapper.find('Cascader').find('input').simulate('click'); - expect( - render(wrapper.find('Cascader').find('Trigger').instance().getComponent()), - ).toMatchSnapshot(); + expect(wrapper.render()).toMatchSnapshot(); }); it('can be selected in RTL direction', () => { @@ -471,81 +423,54 @@ describe('Cascader', () => { , ); - wrapper.find('Cascader').find('input').simulate('click'); - let popupWrapper = mount(wrapper.find('Cascader').find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(0) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); - expect( - render(wrapper.find('Cascader').find('Trigger').instance().getComponent()), - ).toMatchSnapshot(); - popupWrapper = mount(wrapper.find('Cascader').find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(1) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); - expect( - render(wrapper.find('Cascader').find('Trigger').instance().getComponent()), - ).toMatchSnapshot(); - popupWrapper = mount(wrapper.find('Cascader').find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(2) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); + toggleOpen(wrapper); + clickOption(wrapper, 0, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + toggleOpen(wrapper); + clickOption(wrapper, 1, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + toggleOpen(wrapper); + clickOption(wrapper, 2, 0); + expect(getDropdown(wrapper).render()).toMatchSnapshot(); + + expect(onChange).toHaveBeenCalledTimes(1); expect(onChange).toHaveBeenCalledWith(['zhejiang', 'hangzhou', 'xihu'], expect.anything()); }); it('defaultValue works correctly when no match options', () => { const wrapper = mount(); - expect(wrapper.find('.ant-cascader-picker-label').text()).toBe('options1 / options2'); + expect(wrapper.find('.ant-select-selection-item').text()).toEqual('options1 / options2'); }); it('can be selected when showSearch', () => { const onChange = jest.fn(); const wrapper = mount(); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'Zh' } }); - const popupWrapper = mount(wrapper.find('Cascader').find('Trigger').instance().getComponent()); - expect(popupWrapper.find('.ant-cascader-menu').length).toBe(1); - popupWrapper - .find('.ant-cascader-menu') - .at(0) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); + expect(wrapper.find('.ant-cascader-menu').length).toBe(1); + clickOption(wrapper, 0, 0); expect(onChange).toHaveBeenCalledWith(['zhejiang', 'hangzhou', 'xihu'], expect.anything()); }); it('options should open after press esc and then search', () => { const wrapper = mount(); wrapper.find('input').simulate('change', { target: { value: 'jin' } }); - wrapper.find('input').simulate('keydown', { keyCode: KeyCode.ESC }); + expect(isOpen(wrapper)).toBeTruthy(); + wrapper.find('input').simulate('keydown', { which: KeyCode.ESC }); + expect(isOpen(wrapper)).toBeFalsy(); wrapper.find('input').simulate('change', { target: { value: 'jin' } }); - expect(wrapper.state('popupVisible')).toBe(true); + expect(isOpen(wrapper)).toBeTruthy(); }); it('onChange works correctly when the label of fieldNames is the same as value', () => { const onChange = jest.fn(); - const sameNames = { label: 'label', value: 'label', children: 'children' }; + const sameNames = { label: 'label', value: 'label' }; const wrapper = mount( , ); - wrapper.find('input').simulate('click'); wrapper.find('input').simulate('change', { target: { value: 'est' } }); - const popupWrapper = mount(wrapper.find('Cascader').find('Trigger').instance().getComponent()); - popupWrapper - .find('.ant-cascader-menu') - .at(0) - .find('.ant-cascader-menu-item') - .at(0) - .simulate('click'); + clickOption(wrapper, 0, 0); expect(onChange).toHaveBeenCalledWith(['Zhejiang', 'Hangzhou', 'West Lake'], expect.anything()); }); }); diff --git a/components/cascader/demo/multiple.md b/components/cascader/demo/multiple.md new file mode 100644 index 0000000000..eace52a404 --- /dev/null +++ b/components/cascader/demo/multiple.md @@ -0,0 +1,67 @@ +--- +order: 5.1 +title: + zh-CN: 多选 + en-US: Multiple +--- + +## zh-CN + +一次性选择多个选项。 + +## en-US + +Select multiple options + +```jsx +import { Cascader } from 'antd'; + +const options = [ + { + label: 'Light', + value: 'light', + children: new Array(20) + .fill(null) + .map((_, index) => ({ label: `Number ${index}`, value: index })), + }, + { + label: 'Bamboo', + value: 'bamboo', + children: [ + { + label: 'Little', + value: 'little', + children: [ + { + label: 'Toy Fish', + value: 'fish', + }, + { + label: 'Toy Cards', + value: 'cards', + }, + { + label: 'Toy Bird', + value: 'bird', + }, + ], + }, + ], + }, +]; + +function onChange(value) { + console.log(value); +} + +ReactDOM.render( + , + mountNode, +); +``` diff --git a/components/cascader/index.en-US.md b/components/cascader/index.en-US.md index 5b3de3ab27..1ec137bf17 100644 --- a/components/cascader/index.en-US.md +++ b/components/cascader/index.en-US.md @@ -36,18 +36,18 @@ Cascade selection box. | getPopupContainer | Parent Node which the selector should be rendered to. Default to `body`. When position issues happen, try to modify it into scrollable content and position it relative. [example](https://codepen.io/afc163/pen/zEjNOy?editors=0010) | function(triggerNode) | () => document.body | | | loadData | To load option lazily, and it cannot work with `showSearch` | (selectedOptions) => void | - | | | notFoundContent | Specify content to show when no result matches | string | `Not Found` | | +| open | Set visible of cascader popup | boolean | - | 4.17.0 | | options | The data options of cascade | [Option](#Option)\[] | - | | | placeholder | The input placeholder | string | `Please select` | | | popupClassName | The additional className of popup overlay | string | - | | | popupPlacement | Use preset popup align config from builtinPlacements:`bottomLeft` `bottomRight` `topLeft` `topRight` | string | `bottomLeft` | | -| popupVisible | Set visible of cascader popup | boolean | - | | | showSearch | Whether show search input in single mode | boolean \| [Object](#showSearch) | false | | | size | The input size | `large` \| `middle` \| `small` | - | | | style | The additional style | CSSProperties | - | | | suffixIcon | The custom suffix icon | ReactNode | - | | | value | The selected value | string\[] \| number\[] | - | | | onChange | Callback when finishing cascader select | (value, selectedOptions) => void | - | | -| onPopupVisibleChange | Callback when popup shown or hidden | (value) => void | - | | +| onDropdownVisibleChange | Callback when popup shown or hidden | (value) => void | - | 4.17.0 | ### showSearch diff --git a/components/cascader/index.tsx b/components/cascader/index.tsx index d5be7cc3e7..6de339baca 100644 --- a/components/cascader/index.tsx +++ b/components/cascader/index.tsx @@ -1,716 +1,215 @@ import * as React from 'react'; -import RcCascader from 'rc-cascader'; -import arrayTreeFilter from 'array-tree-filter'; import classNames from 'classnames'; +import RcCascader from 'rc-cascader'; +import type { CascaderProps as RcCascaderProps } from 'rc-cascader'; +import type { ShowSearchType, FieldNames } from 'rc-cascader/lib/interface'; import omit from 'rc-util/lib/omit'; -import KeyCode from 'rc-util/lib/KeyCode'; -import CloseCircleFilled from '@ant-design/icons/CloseCircleFilled'; -import DownOutlined from '@ant-design/icons/DownOutlined'; import RightOutlined from '@ant-design/icons/RightOutlined'; import RedoOutlined from '@ant-design/icons/RedoOutlined'; import LeftOutlined from '@ant-design/icons/LeftOutlined'; - -import Input from '../input'; -import { - ConfigConsumer, - ConfigConsumerProps, - RenderEmptyHandler, - DirectionType, -} from '../config-provider'; -import LocaleReceiver from '../locale-provider/LocaleReceiver'; -import devWarning from '../_util/devWarning'; -import SizeContext, { SizeType } from '../config-provider/SizeContext'; -import { replaceElement } from '../_util/reactNode'; +import { ConfigContext } from '../config-provider'; +import type { SizeType } from '../config-provider/SizeContext'; +import SizeContext from '../config-provider/SizeContext'; +import getIcons from '../select/utils/iconUtil'; import { getTransitionName } from '../_util/motion'; -export interface CascaderOptionType { - value?: string | number; - label?: React.ReactNode; - disabled?: boolean; - isLeaf?: boolean; - loading?: boolean; - children?: Array; - [key: string]: any; +// Align the design since we use `rc-select` in root. This help: +// - List search content will show all content +// - Hover opacity style +// - Search filter match case + +export type FieldNamesType = FieldNames; + +export type FilledFieldNamesType = Required; + +function highlightKeyword(str: string, lowerKeyword: string, prefixCls: string | undefined) { + const cells = str + .toLowerCase() + .split(lowerKeyword) + .reduce((list, cur, index) => (index === 0 ? [cur] : [...list, lowerKeyword, cur]), []); + const fillCells: React.ReactNode[] = []; + let start = 0; + + cells.forEach((cell, index) => { + const end = start + cell.length; + let originWorld: React.ReactNode = str.slice(start, end); + start = end; + + if (index % 2 === 1) { + originWorld = ( + + {originWorld} + + ); + } + + fillCells.push(originWorld); + }); + + return fillCells; } -export interface FieldNamesType { - value?: string | number; - label?: string; - children?: string; -} +const defaultSearchRender: ShowSearchType['render'] = (inputValue, path, prefixCls, fieldNames) => { + const optionList: React.ReactNode[] = []; -export interface FilledFieldNamesType { - value: string | number; - label: string; - children: string; -} + // We do lower here to save perf + const lower = inputValue.toLowerCase(); -export type CascaderExpandTrigger = 'click' | 'hover'; + path.forEach((node, index) => { + if (index !== 0) { + optionList.push(' / '); + } -export type CascaderValueType = (string | number)[]; + let label = (node as any)[fieldNames.label!]; + const type = typeof label; + if (type === 'string' || type === 'number') { + label = highlightKeyword(String(label), lower, prefixCls); + } -export interface ShowSearchType { - filter?: (inputValue: string, path: CascaderOptionType[], names: FilledFieldNamesType) => boolean; - render?: ( - inputValue: string, - path: CascaderOptionType[], - prefixCls: string | undefined, - names: FilledFieldNamesType, - ) => React.ReactNode; - sort?: ( - a: CascaderOptionType[], - b: CascaderOptionType[], - inputValue: string, - names: FilledFieldNamesType, - ) => number; - matchInputWidth?: boolean; - limit?: number | false; -} + optionList.push(label); + }); + return optionList; +}; -export interface CascaderProps { - /** 可选项数据源 */ - options: CascaderOptionType[]; - /** 默认的选中项 */ - defaultValue?: CascaderValueType; - /** 指定选中项 */ - value?: CascaderValueType; - /** 选择完成后的回调 */ - onChange?: (value: CascaderValueType, selectedOptions?: CascaderOptionType[]) => void; - /** 选择后展示的渲染函数 */ - displayRender?: (label: string[], selectedOptions?: CascaderOptionType[]) => React.ReactNode; - /** 自定义样式 */ - style?: React.CSSProperties; - /** 自定义类名 */ - className?: string; - /** 自定义浮层类名 */ - popupClassName?: string; - /** 浮层预设位置:`bottomLeft` `bottomRight` `topLeft` `topRight` */ - popupPlacement?: string; - /** 输入框占位文本 */ - placeholder?: string; - /** 输入框大小,可选 `large` `default` `small` */ +export interface CascaderProps extends Omit { + multiple?: boolean; size?: SizeType; - /** 输入框 name */ - name?: string; - /** 输入框 id */ - id?: string; - /** Whether has border style */ bordered?: boolean; - /** 禁用 */ - disabled?: boolean; - /** 是否支持清除 */ - allowClear?: boolean; - /** 自动获取焦点 */ - autoFocus?: boolean; - showSearch?: boolean | ShowSearchType; - notFoundContent?: React.ReactNode; - loadData?: (selectedOptions?: CascaderOptionType[]) => void; - /** 次级菜单的展开方式,可选 'click' 和 'hover' */ - expandTrigger?: CascaderExpandTrigger; - expandIcon?: React.ReactNode; - /** 当此项为 true 时,点选每级菜单选项值都会发生变化 */ - changeOnSelect?: boolean; - /** 浮层可见变化时回调 */ - onPopupVisibleChange?: (popupVisible: boolean) => void; - prefixCls?: string; - inputPrefixCls?: string; - getPopupContainer?: (triggerNode: HTMLElement) => HTMLElement; - popupVisible?: boolean; - /** Use this after antd@3.7.0 */ - fieldNames?: FieldNamesType; - suffixIcon?: React.ReactNode; - dropdownRender?: (menus: React.ReactNode) => React.ReactNode; - - // Miss prop defines. - autoComplete?: string; - transitionName?: string; - children?: React.ReactElement; } -export interface CascaderState { - inputFocused: boolean; - inputValue: string; - value: CascaderValueType; - popupVisible: boolean | undefined; - flattenOptions: CascaderOptionType[][] | undefined; - prevProps: CascaderProps; +interface CascaderRef { + focus: () => void; + blur: () => void; } -interface CascaderLocale { - placeholder?: string; -} +const Cascader = React.forwardRef((props: CascaderProps, ref: React.Ref) => { + const { + prefixCls: customizePrefixCls, + size: customizeSize, + className, + multiple, + bordered = true, + transitionName, + choiceTransitionName = '', + dropdownClassName, + expandIcon, + showSearch, + allowClear = true, + notFoundContent, + direction, + ...rest + } = props; -// We limit the filtered item count by default -const defaultLimit = 50; + const restProps = omit(rest, ['suffixIcon' as any]); -// keep value when filtering -const keepFilteredValueField = '__KEEP_FILTERED_OPTION_VALUE'; + const { + // getPopupContainer: getContextPopupContainer, + getPrefixCls, + renderEmpty, + direction: rootDirection, + // virtual, + // dropdownMatchSelectWidth, + } = React.useContext(ConfigContext); -function highlightKeyword(str: string, keyword: string, prefixCls: string | undefined) { - return str.split(keyword).map((node: string, index: number) => - index === 0 - ? node - : [ - - {keyword} - , - node, - ], - ); -} + const mergedDirection = direction || rootDirection; + const isRtl = mergedDirection === 'rtl'; -function defaultFilterOption( - inputValue: string, - path: CascaderOptionType[], - names: FilledFieldNamesType, -) { - return path.some(option => (option[names.label] as string).indexOf(inputValue) > -1); -} + // =================== No Found ==================== + const mergedNotFoundContent = notFoundContent || renderEmpty('Cascader'); -function defaultRenderFilteredOption( - inputValue: string, - path: CascaderOptionType[], - prefixCls: string | undefined, - names: FilledFieldNamesType, -) { - return path.map((option, index) => { - const label = option[names.label]; - const node = - (label as string).indexOf(inputValue) > -1 - ? highlightKeyword(label as string, inputValue, prefixCls) - : label; - return index === 0 ? node : [' / ', node]; + // ==================== Prefix ===================== + const rootPrefixCls = getPrefixCls(); + const prefixCls = getPrefixCls('select', customizePrefixCls); + const cascaderPrefixCls = getPrefixCls('cascader', customizePrefixCls); + + // =================== Dropdown ==================== + const mergedDropdownClassName = classNames(dropdownClassName, `${cascaderPrefixCls}-dropdown`, { + [`${cascaderPrefixCls}-dropdown-rtl`]: mergedDirection === 'rtl', }); -} -function defaultSortFilteredOption( - a: CascaderOptionType[], - b: CascaderOptionType[], - inputValue: string, - names: FilledFieldNamesType, -) { - function callback(elem: CascaderOptionType) { - return (elem[names.label] as string).indexOf(inputValue) > -1; - } - - return a.findIndex(callback) - b.findIndex(callback); -} - -function getFieldNames({ fieldNames }: CascaderProps) { - return fieldNames; -} - -function getFilledFieldNames(props: CascaderProps) { - const fieldNames = getFieldNames(props) || {}; - const names: FilledFieldNamesType = { - children: fieldNames.children || 'children', - label: fieldNames.label || 'label', - value: fieldNames.value || 'value', - }; - return names; -} - -function flattenTree( - options: CascaderOptionType[], - props: CascaderProps, - ancestor: CascaderOptionType[] = [], -) { - const names: FilledFieldNamesType = getFilledFieldNames(props); - let flattenOptions: CascaderOptionType[][] = []; - const childrenName = names.children; - options.forEach(option => { - const path = ancestor.concat(option); - if (props.changeOnSelect || !option[childrenName] || !option[childrenName].length) { - flattenOptions.push(path); + // ==================== Search ===================== + const mergedShowSearch = React.useMemo(() => { + if (!showSearch) { + return showSearch; } - if (option[childrenName]) { - flattenOptions = flattenOptions.concat(flattenTree(option[childrenName], props, path)); - } - }); - return flattenOptions; -} -const defaultDisplayRender = (label: string[]) => label.join(' / '); - -function warningValueNotExist(list: CascaderOptionType[], fieldNames: FieldNamesType = {}) { - (list || []).forEach(item => { - const valueFieldName = fieldNames.value || 'value'; - devWarning(valueFieldName in item, 'Cascader', 'Not found `value` in `options`.'); - warningValueNotExist(item[fieldNames.children || 'children'], fieldNames); - }); -} - -function getEmptyNode( - renderEmpty: RenderEmptyHandler, - names: FilledFieldNamesType, - notFoundContent?: React.ReactNode, -) { - return { - [names.value]: 'ANT_CASCADER_NOT_FOUND', - [names.label]: notFoundContent || renderEmpty('Cascader'), - disabled: true, - isEmptyNode: true, - }; -} - -class Cascader extends React.Component { - static defaultProps = { - options: [], - disabled: false, - allowClear: true, - bordered: true, - }; - - static getDerivedStateFromProps(nextProps: CascaderProps, { prevProps }: CascaderState) { - const newState: Partial = { - prevProps: nextProps, + let searchConfig: ShowSearchType = { + render: defaultSearchRender, }; - if ('value' in nextProps) { - newState.value = nextProps.value || []; - } - if ('popupVisible' in nextProps) { - newState.popupVisible = nextProps.popupVisible; - } - if (nextProps.showSearch && prevProps.options !== nextProps.options) { - newState.flattenOptions = flattenTree(nextProps.options, nextProps); + if (typeof showSearch === 'object') { + searchConfig = { + ...searchConfig, + ...showSearch, + }; } - if (process.env.NODE_ENV !== 'production' && nextProps.options) { - warningValueNotExist(nextProps.options, getFieldNames(nextProps)); - } + return searchConfig; + }, [showSearch]); - return newState; + // ===================== Size ====================== + const size = React.useContext(SizeContext); + const mergedSize = customizeSize || size; + + // ===================== Icon ====================== + let mergedExpandIcon = expandIcon; + if (!expandIcon) { + mergedExpandIcon = isRtl ? : ; } - cachedOptions: CascaderOptionType[] = []; - - clearSelectionTimeout: any; - - private input: Input; - - constructor(props: CascaderProps) { - super(props); - this.state = { - value: props.value || props.defaultValue || [], - inputValue: '', - inputFocused: false, - popupVisible: props.popupVisible, - flattenOptions: props.showSearch ? flattenTree(props.options, props) : undefined, - prevProps: props, - }; - } - - componentWillUnmount() { - if (this.clearSelectionTimeout) { - clearTimeout(this.clearSelectionTimeout); - } - } - - setValue = (value: CascaderValueType, selectedOptions: CascaderOptionType[] = []) => { - if (!('value' in this.props)) { - this.setState({ value }); - } - const { onChange } = this.props; - onChange?.(value, selectedOptions); - }; - - getLabel() { - const { options, displayRender = defaultDisplayRender } = this.props; - const names = getFilledFieldNames(this.props); - const { value } = this.state; - const unwrappedValue = Array.isArray(value[0]) ? value[0] : value; - const selectedOptions: CascaderOptionType[] = arrayTreeFilter( - options, - (o: CascaderOptionType, level: number) => o[names.value] === unwrappedValue[level], - { childrenKeyName: names.children }, - ); - const label = selectedOptions.length ? selectedOptions.map(o => o[names.label]) : value; - return displayRender(label, selectedOptions); - } - - saveInput = (node: Input) => { - this.input = node; - }; - - handleChange = (value: any, selectedOptions: CascaderOptionType[]) => { - this.setState({ inputValue: '' }); - if (selectedOptions[0].__IS_FILTERED_OPTION) { - const unwrappedValue = - selectedOptions[0][keepFilteredValueField] === undefined - ? value[0] - : selectedOptions[0][keepFilteredValueField]; - const unwrappedSelectedOptions = selectedOptions[0].path; - this.setValue(unwrappedValue, unwrappedSelectedOptions); - return; - } - this.setValue(value, selectedOptions); - }; - - handlePopupVisibleChange = (popupVisible: boolean) => { - if (!('popupVisible' in this.props)) { - this.setState(state => ({ - popupVisible, - inputFocused: popupVisible, - inputValue: popupVisible ? state.inputValue : '', - })); - } - - const { onPopupVisibleChange } = this.props; - onPopupVisibleChange?.(popupVisible); - }; - - handleInputBlur = () => { - this.setState({ - inputFocused: false, - }); - }; - - handleInputClick = (e: React.MouseEvent) => { - const { inputFocused, popupVisible } = this.state; - // Prevent `Trigger` behaviour. - if (inputFocused || popupVisible) { - e.stopPropagation(); - } - }; - - handleKeyDown = (e: React.KeyboardEvent) => { - // SPACE => https://github.com/ant-design/ant-design/issues/16871 - if (e.keyCode === KeyCode.BACKSPACE || e.keyCode === KeyCode.SPACE) { - e.stopPropagation(); - } - }; - - handleInputChange = (e: React.ChangeEvent) => { - const { popupVisible } = this.state; - const inputValue = e.target.value; - if (!popupVisible) { - this.handlePopupVisibleChange(true); - } - this.setState({ inputValue }); - }; - - clearSelection = (e: React.MouseEvent) => { - const { inputValue } = this.state; - e.preventDefault(); - e.stopPropagation(); - if (!inputValue) { - this.handlePopupVisibleChange(false); - this.clearSelectionTimeout = setTimeout(() => { - this.setValue([]); - }, 200); - } else { - this.setState({ inputValue: '' }); - } - }; - - generateFilteredOptions(prefixCls: string | undefined, renderEmpty: RenderEmptyHandler) { - const { showSearch, notFoundContent } = this.props; - const names: FilledFieldNamesType = getFilledFieldNames(this.props); - const { - filter = defaultFilterOption, - render = defaultRenderFilteredOption, - sort = defaultSortFilteredOption, - limit = defaultLimit, - } = showSearch as ShowSearchType; - const { flattenOptions = [], inputValue } = this.state; - - // Limit the filter if needed - let filtered: Array; - if (limit > 0) { - filtered = []; - let matchCount = 0; - - // Perf optimization to filter items only below the limit - flattenOptions.some(path => { - const match = filter(this.state.inputValue, path, names); - if (match) { - filtered.push(path); - matchCount += 1; - } - return matchCount >= limit; - }); - } else { - devWarning( - typeof limit !== 'number', - 'Cascader', - "'limit' of showSearch should be positive number or false.", - ); - filtered = flattenOptions.filter(path => filter(this.state.inputValue, path, names)); - } - - filtered = filtered.sort((a, b) => sort(a, b, inputValue, names)); - - if (filtered.length > 0) { - // Fix issue: https://github.com/ant-design/ant-design/issues/26554 - const field = names.value === names.label ? keepFilteredValueField : names.value; - - return filtered.map( - (path: CascaderOptionType[]) => - ({ - __IS_FILTERED_OPTION: true, - path, - [field]: path.map((o: CascaderOptionType) => o[names.value]), - [names.label]: render(inputValue, path, prefixCls, names), - disabled: path.some((o: CascaderOptionType) => !!o.disabled), - isEmptyNode: true, - } as CascaderOptionType), - ); - } - return [getEmptyNode(renderEmpty, names, notFoundContent)]; - } - - focus() { - this.input.focus(); - } - - blur() { - this.input.blur(); - } - - getPopupPlacement(direction: DirectionType = 'ltr') { - const { popupPlacement } = this.props; - if (popupPlacement !== undefined) { - return popupPlacement; - } - return direction === 'rtl' ? 'bottomRight' : 'bottomLeft'; - } - - renderCascader = ( - { - getPopupContainer: getContextPopupContainer, - getPrefixCls, - renderEmpty, - direction, - }: ConfigConsumerProps, - locale: CascaderLocale, - ) => ( - - {size => { - const { props, state } = this; - const { - prefixCls: customizePrefixCls, - inputPrefixCls: customizeInputPrefixCls, - children, - placeholder = locale.placeholder || 'Please select', - size: customizeSize, - disabled, - className, - style, - allowClear, - showSearch = false, - suffixIcon, - expandIcon, - notFoundContent, - popupClassName, - bordered, - dropdownRender, - ...otherProps - } = props; - - const mergedSize = customizeSize || size; - - const { value, inputFocused } = state; - - const isRtlLayout = direction === 'rtl'; - - const prefixCls = getPrefixCls('cascader', customizePrefixCls); - const inputPrefixCls = getPrefixCls('input', customizeInputPrefixCls); - - const sizeCls = classNames({ - [`${inputPrefixCls}-lg`]: mergedSize === 'large', - [`${inputPrefixCls}-sm`]: mergedSize === 'small', - }); - const clearIcon = - (allowClear && !disabled && value.length > 0) || state.inputValue ? ( - - ) : null; - const arrowCls = classNames({ - [`${prefixCls}-picker-arrow`]: true, - [`${prefixCls}-picker-arrow-expand`]: state.popupVisible, - }); - const pickerCls = classNames( - `${prefixCls}-picker`, - { - [`${prefixCls}-picker-rtl`]: isRtlLayout, - [`${prefixCls}-picker-with-value`]: state.inputValue, - [`${prefixCls}-picker-disabled`]: disabled, - [`${prefixCls}-picker-${mergedSize}`]: !!mergedSize, - [`${prefixCls}-picker-show-search`]: !!showSearch, - [`${prefixCls}-picker-focused`]: inputFocused, - [`${prefixCls}-picker-borderless`]: !bordered, - }, - className, - ); - - // Fix bug of https://github.com/facebook/react/pull/5004 - // and https://fb.me/react-unknown-prop - const inputProps = omit( - // Not know why these props left - otherProps as typeof otherProps & { - filterOption: any; - renderFilteredOption: any; - sortFilteredOption: any; - defaultValue: any; - }, - [ - 'onChange', - 'options', - 'popupPlacement', - 'transitionName', - 'displayRender', - 'onPopupVisibleChange', - 'changeOnSelect', - 'expandTrigger', - 'popupVisible', - 'getPopupContainer', - 'loadData', - 'filterOption', - 'renderFilteredOption', - 'sortFilteredOption', - 'fieldNames', - ], - ); - - let { options } = props; - const names: FilledFieldNamesType = getFilledFieldNames(this.props); - if (options && options.length > 0) { - if (state.inputValue) { - options = this.generateFilteredOptions(prefixCls, renderEmpty); - } - } else { - options = [getEmptyNode(renderEmpty, names, notFoundContent)]; - } - // Dropdown menu should keep previous status until it is fully closed. - if (!state.popupVisible) { - options = this.cachedOptions; - } else { - this.cachedOptions = options; - } - - const dropdownMenuColumnStyle: { width?: number; height?: string } = {}; - const isNotFound = (options || []).length === 1 && options[0].isEmptyNode; - if (isNotFound) { - dropdownMenuColumnStyle.height = 'auto'; // Height of one row. - } - // The default value of `matchInputWidth` is `true` - const resultListMatchInputWidth = (showSearch as ShowSearchType).matchInputWidth !== false; - if (resultListMatchInputWidth && (state.inputValue || isNotFound) && this.input) { - dropdownMenuColumnStyle.width = this.input.input.offsetWidth; - } - - let inputIcon: React.ReactNode; - if (suffixIcon) { - inputIcon = replaceElement( - suffixIcon, - {suffixIcon}, - () => ({ - className: classNames({ - [(suffixIcon as any).props.className!]: (suffixIcon as any).props.className, - [`${prefixCls}-picker-arrow`]: true, - }), - }), - ); - } else { - inputIcon = ; - } - - const label = this.getLabel(); - const input: React.ReactElement = children || ( - - - {label} - - 0 ? undefined : placeholder} - className={`${prefixCls}-input ${sizeCls}`} - value={state.inputValue} - disabled={disabled} - readOnly={!showSearch} - autoComplete={inputProps.autoComplete || 'off'} - onClick={showSearch ? this.handleInputClick : undefined} - onBlur={showSearch ? this.handleInputBlur : undefined} - onKeyDown={this.handleKeyDown} - onChange={showSearch ? this.handleInputChange : undefined} - /> - {clearIcon} - {inputIcon} - - ); - - let expandIconNode; - if (expandIcon) { - expandIconNode = expandIcon; - } else { - expandIconNode = isRtlLayout ? : ; - } - - const loadingIcon = ( - - - - ); - - const getPopupContainer = props.getPopupContainer || getContextPopupContainer; - const rest = omit(props as typeof props & { inputIcon: any; loadingIcon: any }, [ - 'inputIcon', - 'expandIcon', - 'loadingIcon', - 'bordered', - 'className', - ]); - const rcCascaderPopupClassName = classNames(popupClassName, { - [`${prefixCls}-menu-${direction}`]: direction === 'rtl', - [`${prefixCls}-menu-empty`]: - options.length === 1 && options[0].value === 'ANT_CASCADER_NOT_FOUND', - }); - const rootPrefixCls = getPrefixCls(); - - return ( - - {input} - - ); - }} - + const loadingIcon = ( + + + ); - render() { - return ( - - {(configArgument: ConfigConsumerProps) => ( - {locale => this.renderCascader(configArgument, locale)} - )} - - ); - } -} + // =================== Multiple ==================== + const checkable = React.useMemo( + () => (multiple ? : false), + [multiple], + ); + + // ===================== Icons ===================== + const { suffixIcon, removeIcon, clearIcon } = getIcons({ + ...props, + multiple, + prefixCls, + }); + + // ==================== Render ===================== + return ( + + ); +}); + +Cascader.displayName = 'Cascader'; export default Cascader; diff --git a/components/cascader/index.zh-CN.md b/components/cascader/index.zh-CN.md index 97c18c5c7c..c88a08e98a 100644 --- a/components/cascader/index.zh-CN.md +++ b/components/cascader/index.zh-CN.md @@ -37,18 +37,18 @@ cover: https://gw.alipayobjects.com/zos/alicdn/UdS8y8xyZ/Cascader.svg | getPopupContainer | 菜单渲染父节点。默认渲染到 body 上,如果你遇到菜单滚动定位问题,试试修改为滚动的区域,并相对其定位。[示例](https://codepen.io/afc163/pen/zEjNOy?editors=0010) | function(triggerNode) | () => document.body | | | loadData | 用于动态加载选项,无法与 `showSearch` 一起使用 | (selectedOptions) => void | - | | | notFoundContent | 当下拉列表为空时显示的内容 | string | `Not Found` | | +| open | 控制浮层显隐 | boolean | - | 4.17.0 | | options | 可选项数据源 | [Option](#Option)\[] | - | | | placeholder | 输入框占位文本 | string | `请选择` | | | popupClassName | 自定义浮层类名 | string | - | | | popupPlacement | 浮层预设位置:`bottomLeft` `bottomRight` `topLeft` `topRight` | string | `bottomLeft` | | -| popupVisible | 控制浮层显隐 | boolean | - | | | showSearch | 在选择框中显示搜索框 | boolean \| [Object](#showSearch) | false | | | size | 输入框大小 | `large` \| `middle` \| `small` | - | | | style | 自定义样式 | CSSProperties | - | | | suffixIcon | 自定义的选择框后缀图标 | ReactNode | - | | | value | 指定选中项 | string\[] \| number\[] | - | | | onChange | 选择完成后的回调 | (value, selectedOptions) => void | - | | -| onPopupVisibleChange | 显示/隐藏浮层的回调 | (value) => void | - | | +| onDropdownVisibleChange | 显示/隐藏浮层的回调 | (value) => void | - | 4.17.0 | ### showSearch diff --git a/components/cascader/style/index.less b/components/cascader/style/index.less index b0a78fcc60..7e8d93515d 100644 --- a/components/cascader/style/index.less +++ b/components/cascader/style/index.less @@ -1,172 +1,38 @@ @import '../../style/themes/index'; @import '../../style/mixins/index'; @import '../../input/style/mixin'; +@import '../../checkbox/style/mixin'; @cascader-prefix-cls: ~'@{ant-prefix}-cascader'; +.antCheckboxFn(@checkbox-prefix-cls: ~'@{cascader-prefix-cls}-checkbox'); + .@{cascader-prefix-cls} { - .reset-component(); + width: 184px; - &-input.@{ant-prefix}-input { - // Keep it static for https://github.com/ant-design/ant-design/issues/16738 - position: static; - width: 100%; - // https://github.com/ant-design/ant-design/issues/17582 - padding-right: 24px; - // Add important to fix https://github.com/ant-design/ant-design/issues/5078 - // because input.less will compile after cascader.less - background-color: transparent !important; - cursor: pointer; - } - - &-picker-show-search &-input.@{ant-prefix}-input { - position: relative; - } - - &-picker { - .reset-component(); - - position: relative; - display: inline-block; - background-color: @cascader-bg; - border-radius: @border-radius-base; - outline: 0; - cursor: pointer; - transition: color 0.3s; - - &-with-value &-label { - color: transparent; - } - - &-disabled { - color: @disabled-color; - background: @input-disabled-bg; - cursor: not-allowed; - .@{cascader-prefix-cls}-input { - cursor: not-allowed; - } - } - - &:focus .@{cascader-prefix-cls}-input { - .active(); - } - - &-borderless .@{cascader-prefix-cls}-input { - border-color: transparent !important; - box-shadow: none !important; - } - - &-show-search&-focused { - color: @disabled-color; - } - - &-label { - position: absolute; - top: 50%; - left: 0; - width: 100%; - height: 20px; - margin-top: -10px; - padding: 0 20px 0 @control-padding-horizontal; - overflow: hidden; - line-height: 20px; - white-space: nowrap; - text-overflow: ellipsis; - } - - &-clear { - position: absolute; - top: 50%; - right: @control-padding-horizontal; - z-index: 2; - width: 12px; - height: 12px; - margin-top: -6px; - color: @disabled-color; - font-size: @font-size-sm; - line-height: 12px; - background: @component-background; - cursor: pointer; - opacity: 0; - transition: color 0.3s ease, opacity 0.15s ease; - &:hover { - color: @text-color-secondary; - } - } - - &:hover &-clear { - opacity: 1; - } - - // arrow - &-arrow { - position: absolute; - top: 50%; - right: @control-padding-horizontal; - z-index: 1; - width: 12px; - height: 12px; - margin-top: -6px; - color: @disabled-color; - font-size: 12px; - line-height: 12px; - } - } - - // https://github.com/ant-design/ant-design/pull/12407#issuecomment-424657810 - &-picker-label:hover + &-input { - &:not(.@{cascader-prefix-cls}-picker-disabled &) { - .hover(); - } - } - - &-picker-small &-picker-clear, - &-picker-small &-picker-arrow { - right: @control-padding-horizontal-sm; + &-checkbox { + top: 0; + margin-right: @padding-xs; } &-menus { - position: absolute; - z-index: @zindex-dropdown; - font-size: @cascader-dropdown-font-size; - white-space: nowrap; - background: @cascader-menu-bg; - border-radius: @border-radius-base; - box-shadow: @box-shadow-base; + display: flex; + flex-wrap: nowrap; + align-items: flex-start; - ul, - ol { - margin: 0; - list-style: none; - } - - &-empty, - &-hidden { - display: none; - } - &.@{ant-prefix}-slide-up-enter.@{ant-prefix}-slide-up-enter-active&-placement-bottomLeft, - &.@{ant-prefix}-slide-up-appear.@{ant-prefix}-slide-up-appear-active&-placement-bottomLeft { - animation-name: antSlideUpIn; - } - - &.@{ant-prefix}-slide-up-enter.@{ant-prefix}-slide-up-enter-active&-placement-topLeft, - &.@{ant-prefix}-slide-up-appear.@{ant-prefix}-slide-up-appear-active&-placement-topLeft { - animation-name: antSlideDownIn; - } - - &.@{ant-prefix}-slide-up-leave.@{ant-prefix}-slide-up-leave-active&-placement-bottomLeft { - animation-name: antSlideUpOut; - } - - &.@{ant-prefix}-slide-up-leave.@{ant-prefix}-slide-up-leave-active&-placement-topLeft { - animation-name: antSlideDownOut; + &.@{cascader-prefix-cls}-menu-empty { + .@{cascader-prefix-cls}-menu { + width: 100%; + height: auto; + } } } + &-menu { - display: inline-block; min-width: 111px; height: 180px; margin: 0; + margin: -@dropdown-edge-child-vertical-padding 0; padding: @cascader-dropdown-edge-child-vertical-padding 0; overflow: auto; vertical-align: top; @@ -174,67 +40,62 @@ border-right: @border-width-base @border-style-base @cascader-menu-border-color-split; -ms-overflow-style: -ms-autohiding-scrollbar; // https://github.com/ant-design/ant-design/issues/11857 - &:first-child { - border-radius: @border-radius-base 0 0 @border-radius-base; - } - &:last-child { - margin-right: -1px; - border-right-color: transparent; - border-radius: 0 @border-radius-base @border-radius-base 0; - } - &:only-child { - border-radius: @border-radius-base; - } - } - &-menu-item { - padding: @cascader-dropdown-vertical-padding @control-padding-horizontal; - overflow: hidden; - line-height: @cascader-dropdown-line-height; - white-space: nowrap; - text-overflow: ellipsis; - cursor: pointer; - transition: all 0.3s; - &:hover { - background: @item-hover-bg; - } - &-disabled { - color: @disabled-color; - cursor: not-allowed; - &:hover { - background: transparent; - } - } - .@{cascader-prefix-cls}-menu-empty & { - color: @disabled-color; - cursor: default; - pointer-events: none; - } - &-active:not(&-disabled) { - &, - &:hover { - font-weight: @select-item-selected-font-weight; - background-color: @cascader-item-selected-bg; - } - } - &-expand { - position: relative; - padding-right: 24px; - } + &-item { + display: flex; + flex-wrap: nowrap; + align-items: center; + padding: @cascader-dropdown-vertical-padding @control-padding-horizontal; + overflow: hidden; + line-height: @cascader-dropdown-line-height; + white-space: nowrap; + text-overflow: ellipsis; + cursor: pointer; + transition: all 0.3s; - &-expand &-expand-icon, - &-loading-icon { - position: absolute; - right: @control-padding-horizontal; - color: @text-color-secondary; - font-size: 10px; + &:hover { + background: @item-hover-bg; + } - .@{cascader-prefix-cls}-menu-item-disabled& { + &-disabled { color: @disabled-color; + cursor: not-allowed; + &:hover { + background: transparent; + } } - } - & &-keyword { - color: @highlight-color; + .@{cascader-prefix-cls}-menu-empty & { + color: @disabled-color; + cursor: default; + pointer-events: none; + } + + &-active:not(&-disabled) { + &, + &:hover { + font-weight: @select-item-selected-font-weight; + background-color: @cascader-item-selected-bg; + } + } + + &-content { + flex: auto; + } + + &-expand &-expand-icon, + &-loading-icon { + margin-left: @padding-xss; + color: @text-color-secondary; + font-size: 10px; + + .@{cascader-prefix-cls}-menu-item-disabled& { + color: @disabled-color; + } + } + + &-keyword { + color: @highlight-color; + } } } } diff --git a/components/cascader/style/index.tsx b/components/cascader/style/index.tsx index b64c64e2f9..e07deeea0a 100644 --- a/components/cascader/style/index.tsx +++ b/components/cascader/style/index.tsx @@ -3,4 +3,4 @@ import './index.less'; // style dependencies import '../../empty/style'; -import '../../input/style'; +import '../../select/style'; diff --git a/components/cascader/style/rtl.less b/components/cascader/style/rtl.less index e2afea2dc1..2700993ac8 100644 --- a/components/cascader/style/rtl.less +++ b/components/cascader/style/rtl.less @@ -1,95 +1,17 @@ -@import '../../style/themes/index'; -@import '../../style/mixins/index'; -@import '../../input/style/mixin'; +@import (reference) './index'; -@cascader-prefix-cls: ~'@{ant-prefix}-cascader'; -@picker-rtl-cls: ~'@{cascader-prefix-cls}-picker-rtl'; -@menu-rtl-cls: ~'@{cascader-prefix-cls}-menu-rtl'; - -.@{cascader-prefix-cls} { - &-input.@{ant-prefix}-input { - .@{picker-rtl-cls} & { - padding-right: @input-padding-horizontal-base; - padding-left: 24px; - text-align: right; - } - } - - &-picker { - &-rtl { - direction: rtl; - } - - &-label { - .@{picker-rtl-cls} & { - padding: 0 @control-padding-horizontal 0 20px; - text-align: right; - } - } - - &-clear { - .@{picker-rtl-cls} & { - right: auto; - left: @control-padding-horizontal; - } - } - - &-arrow { - .@{picker-rtl-cls} & { - right: auto; - left: @control-padding-horizontal; - } - } - } - - &-picker-small &-picker-clear, - &-picker-small &-picker-arrow { - .@{picker-rtl-cls}& { - right: auto; - left: @control-padding-horizontal-sm; - } - } - - &-menu { - &-rtl & { - direction: rtl; - border-right: none; - border-left: @border-width-base @border-style-base @border-color-split; - &:first-child { - border-radius: 0 @border-radius-base @border-radius-base 0; - } - &:last-child { - margin-right: 0; - margin-left: -1px; - border-left-color: transparent; - border-radius: @border-radius-base 0 0 @border-radius-base; - } - &:only-child { - border-radius: @border-radius-base; - } - } - } - - &-menu-item { - &-expand { - .@{menu-rtl-cls} & { - padding-right: @control-padding-horizontal; - padding-left: 24px; - } - } - - &-expand &-expand-icon, +.@{cascader-prefix-cls}-rtl { + .@{cascader-prefix-cls}-menu-item { + &-expand-icon, &-loading-icon { - .@{menu-rtl-cls} & { - right: auto; - left: @control-padding-horizontal; - } + margin-right: @padding-xss; + margin-left: 0; } + } - &-loading-icon { - .@{menu-rtl-cls} & { - transform: scaleY(-1); - } - } + .@{cascader-prefix-cls}-checkbox { + top: 0; + margin-right: 0; + margin-left: @padding-xs; } } diff --git a/components/config-provider/__tests__/__snapshots__/components.test.js.snap b/components/config-provider/__tests__/__snapshots__/components.test.js.snap index a74e6fabcd..034aef950c 100644 --- a/components/config-provider/__tests__/__snapshots__/components.test.js.snap +++ b/components/config-provider/__tests__/__snapshots__/components.test.js.snap @@ -9985,231 +9985,339 @@ exports[`ConfigProvider components Carousel prefixCls 1`] = ` `; exports[`ConfigProvider components Cascader configProvider 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Cascader configProvider componentSize large 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Cascader configProvider componentSize middle 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Cascader configProvider virtual and dropdownMatchSelectWidth 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Cascader normal 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Cascader prefixCls 1`] = ` - - - - - + + +
    + - +
    `; exports[`ConfigProvider components Checkbox configProvider 1`] = ` diff --git a/components/empty/__tests__/__snapshots__/demo.test.js.snap b/components/empty/__tests__/__snapshots__/demo.test.js.snap index a1050fcbcf..9af592aadb 100644 --- a/components/empty/__tests__/__snapshots__/demo.test.js.snap +++ b/components/empty/__tests__/__snapshots__/demo.test.js.snap @@ -222,42 +222,60 @@ exports[`renders ./components/empty/demo/config-provider.md correctly 1`] = `

    Cascader

    - - - - - + + +
    + - +

    Transfer

    diff --git a/components/form/__tests__/__snapshots__/demo.test.js.snap b/components/form/__tests__/__snapshots__/demo.test.js.snap index 4e71444b0a..1f82ad2fb5 100644 --- a/components/form/__tests__/__snapshots__/demo.test.js.snap +++ b/components/form/__tests__/__snapshots__/demo.test.js.snap @@ -3430,65 +3430,92 @@ exports[`renders ./components/form/demo/register.md correctly 1`] = `
    - - - Zhejiang / Hangzhou / West Lake - - - - + + + Zhejiang / Hangzhou / West Lake + +
    + - +
    @@ -4596,42 +4623,62 @@ exports[`renders ./components/form/demo/size.md correctly 1`] = `
    - - - - - + + +
    + - +
    @@ -7396,42 +7443,62 @@ exports[`renders ./components/form/demo/validate-static.md correctly 1`] = `
    - - - - - + + +
    + - +
    - - - - - + + + cascader + +
    + -
    +
    - - - - - + + + cascader + +
    + -
    +
    , - - - Zhejiang / Hangzhou / West Lake - - - - + + + Zhejiang / Hangzhou / West Lake + + + -
    , + ,
    @@ -2390,43 +2439,65 @@ exports[`renders ./components/input/demo/group.md correctly 1`] = `
    - - - - - + + + Select Address + + + - + `; diff --git a/package.json b/package.json index 0d2905c42c..1803729fdc 100644 --- a/package.json +++ b/package.json @@ -117,7 +117,7 @@ "copy-to-clipboard": "^3.2.0", "lodash": "^4.17.21", "moment": "^2.25.3", - "rc-cascader": "~1.4.0", + "rc-cascader": "~2.0.0-alpha.17", "rc-checkbox": "~2.3.0", "rc-collapse": "~3.1.0", "rc-dialog": "~8.6.0", diff --git a/tests/shared/demoTest.ts b/tests/shared/demoTest.ts index 213eaa7991..680b42d231 100644 --- a/tests/shared/demoTest.ts +++ b/tests/shared/demoTest.ts @@ -2,6 +2,7 @@ import glob from 'glob'; import { render } from 'enzyme'; import MockDate from 'mockdate'; import moment from 'moment'; +import { excludeWarning } from './excludeWarning'; type CheerIO = ReturnType; type CheerIOElement = CheerIO[0]; @@ -57,6 +58,8 @@ export default function demoTest(component: string, options: Options = {}) { testMethod = test.skip; } testMethod(`renders ${file} correctly`, () => { + const errSpy = excludeWarning(); + MockDate.set(moment('2016-11-22').valueOf()); const demo = require(`../.${file}`).default; // eslint-disable-line global-require, import/no-dynamic-require const wrapper = render(demo); @@ -66,6 +69,8 @@ export default function demoTest(component: string, options: Options = {}) { expect(wrapper).toMatchSnapshot(); MockDate.reset(); + + errSpy(); }); }); } diff --git a/tests/shared/excludeWarning.tsx b/tests/shared/excludeWarning.tsx new file mode 100644 index 0000000000..0aa24c5ee1 --- /dev/null +++ b/tests/shared/excludeWarning.tsx @@ -0,0 +1,27 @@ +const originError = console.error; + +/** This function will remove `useLayoutEffect` server side warning. Since it's useless. */ +export function excludeWarning() { + const errorSpy = jest.spyOn(console, 'error').mockImplementation((msg, ...rest) => { + if (String(msg).includes('useLayoutEffect does nothing on the server')) { + return; + } + originError(msg, ...rest); + }); + + return () => { + errorSpy.mockRestore(); + }; +} + +export default function excludeAllWarning() { + let cleanUp: Function; + + beforeAll(() => { + cleanUp = excludeWarning(); + }); + + afterAll(() => { + cleanUp(); + }); +}