diff --git a/components/auto-complete/OptGroup.tsx b/components/auto-complete/OptGroup.tsx index 2b44d826da..30a19de08b 100644 --- a/components/auto-complete/OptGroup.tsx +++ b/components/auto-complete/OptGroup.tsx @@ -1,7 +1,7 @@ import type { FunctionalComponent } from 'vue'; -import type { OptionGroupData } from '../vc-select/interface'; +import type { DefaultOptionType } from '../select'; -export type OptGroupProps = Omit; +export type OptGroupProps = Omit; export interface OptionGroupFC extends FunctionalComponent { /** Legacy for check if is a Option Group */ diff --git a/components/auto-complete/Option.tsx b/components/auto-complete/Option.tsx index fba28e326d..3fa4771db5 100644 --- a/components/auto-complete/Option.tsx +++ b/components/auto-complete/Option.tsx @@ -1,7 +1,7 @@ import type { FunctionalComponent } from 'vue'; -import type { OptionCoreData } from '../vc-select/interface'; +import type { DefaultOptionType } from '../vc-select/Select'; -export interface OptionProps extends Omit { +export interface OptionProps extends Omit { /** Save for customize data */ [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/components/calendar/Header.tsx b/components/calendar/Header.tsx index 4140751bfb..0c122df619 100644 --- a/components/calendar/Header.tsx +++ b/components/calendar/Header.tsx @@ -45,7 +45,7 @@ function YearSelect(props: SharedProps) { options={options} value={year} class={`${prefixCls}-year-select`} - onChange={numYear => { + onChange={(numYear: number) => { let newDate = generateConfig.setYear(value, numYear); if (validRange) { @@ -108,7 +108,7 @@ function MonthSelect(props: SharedProps) { class={`${prefixCls}-month-select`} value={month} options={options} - onChange={newMonth => { + onChange={(newMonth: number) => { onChange(generateConfig.setMonth(value, newMonth)); }} getPopupContainer={() => divRef!.value!} diff --git a/components/cascader/__tests__/__snapshots__/demo.test.js.snap b/components/cascader/__tests__/__snapshots__/demo.test.js.snap index 4b5c340a73..d395ee25c1 100644 --- a/components/cascader/__tests__/__snapshots__/demo.test.js.snap +++ b/components/cascader/__tests__/__snapshots__/demo.test.js.snap @@ -1,62 +1,159 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`renders ./components/cascader/demo/basic.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
`; exports[`renders ./components/cascader/demo/change-on-select.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
`; -exports[`renders ./components/cascader/demo/custom-render.vue correctly 1`] = `Zhejiang /Hangzhou /West Lake ( 752100 ) `; +exports[`renders ./components/cascader/demo/custom-render.vue correctly 1`] = ` +
+ + +
Zhejiang /Hangzhou /West Lake ( 752100 ) + +
+
+`; -exports[`renders ./components/cascader/demo/custom-trigger.vue correctly 1`] = `Unselect   Change city`; +exports[`renders ./components/cascader/demo/custom-trigger.vue correctly 1`] = `Unselect   Change city`; exports[`renders ./components/cascader/demo/disabled-option.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
`; exports[`renders ./components/cascader/demo/fields-name.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
`; exports[`renders ./components/cascader/demo/hover.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
`; exports[`renders ./components/cascader/demo/lazy.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +
+`; + +exports[`renders ./components/cascader/demo/multiple.vue correctly 1`] = ` +
+ + +
+
+ +
+ +
+
Please select +
+ + +
`; exports[`renders ./components/cascader/demo/search.vue correctly 1`] = ` - - + `; exports[`renders ./components/cascader/demo/size.vue correctly 1`] = ` - - +
+ + +
+ Please select +
+ +


- - +
+ + +
+ Please select +
+ +


- - +
+ + +
+ Please select +
+ +


`; exports[`renders ./components/cascader/demo/suffix.vue correctly 1`] = ` - - - -ab +
+ + +
+ 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 577a0d5638..1ad489d6ff 100644 --- a/components/cascader/__tests__/__snapshots__/index.test.js.snap +++ b/components/cascader/__tests__/__snapshots__/index.test.js.snap @@ -1,116 +1,160 @@ // 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 popup correctly when panel is open 1`] = `
- -
-
-
    - - -
-
+
+
`; exports[`Cascader popup correctly with defaultValue 1`] = `
- -
-
-
    - - -
-
    - -
-
    - -
-
+
+ + +
`; -exports[`Cascader support controlled mode 1`] = `Zhejiang / Hangzhou / West Lake`; +exports[`Cascader support controlled mode 1`] = ` +
+ + +
Zhejiang + +
+
+`; diff --git a/components/cascader/__tests__/index.test.js b/components/cascader/__tests__/index.test.js index 13056583b4..cba23baa41 100644 --- a/components/cascader/__tests__/index.test.js +++ b/components/cascader/__tests__/index.test.js @@ -46,6 +46,14 @@ function filter(inputValue, path) { return path.some(option => option.label.toLowerCase().indexOf(inputValue.toLowerCase()) > -1); } +function toggleOpen(wrapper) { + wrapper.find('.ant-select-selector').trigger('mousedown'); +} + +function isOpen(wrapper) { + return !!wrapper.findComponent({ name: 'Trigger' }).props().popupVisible; +} + describe('Cascader', () => { focusTest(Cascader); beforeEach(() => { @@ -65,7 +73,7 @@ describe('Cascader', () => { it('popup correctly when panel is open', async () => { const wrapper = mount(Cascader, { props: { options }, sync: false, attachTo: 'body' }); await asyncExpect(() => { - wrapper.find('input').trigger('click'); + toggleOpen(wrapper); }); expect($$('.ant-cascader-menus').length).toBe(1); await asyncExpect(() => { @@ -95,7 +103,7 @@ describe('Cascader', () => { }); await asyncExpect(() => { - wrapper.find('input').trigger('click'); + toggleOpen(wrapper); }); expect($$('.ant-cascader-menus').length).toBe(1); await asyncExpect(() => { @@ -106,9 +114,8 @@ describe('Cascader', () => { it('can be selected', async () => { const wrapper = mount(Cascader, { props: { options }, sync: false }); await asyncExpect(() => { - wrapper.find('input').trigger('click'); + toggleOpen(wrapper); }); - await asyncExpect(() => { $$('.ant-cascader-menu')[0].querySelectorAll('.ant-cascader-menu-item')[0].click(); }); @@ -134,23 +141,36 @@ describe('Cascader', () => { }); }); - it('backspace should work with `Cascader[showSearch]`', async () => { + fit('backspace should work with `Cascader[showSearch]`', async () => { const wrapper = mount(Cascader, { props: { options, showSearch: true }, sync: false }); await asyncExpect(() => { wrapper.find('input').element.value = '123'; wrapper.find('input').trigger('input'); }); await asyncExpect(() => { - expect(wrapper.vm.inputValue).toBe('123'); + expect(isOpen(wrapper)).toBeTruthy(); }); await asyncExpect(() => { wrapper.find('input').element.keyCode = KeyCode.BACKSPACE; wrapper.find('input').trigger('keydown'); }); await asyncExpect(() => { - // trigger onKeyDown will not trigger onChange by default, so the value is still '123' - expect(wrapper.vm.inputValue).toBe('123'); + expect(isOpen(wrapper)).toBeTruthy(); + }); + await asyncExpect(() => { + wrapper.find('input').element.value = ''; + wrapper.find('input').trigger('input'); + }); + await asyncExpect(() => { + expect(isOpen(wrapper)).toBeTruthy(); }); + // await asyncExpect(() => { + // wrapper.find('input').element.keyCode = KeyCode.BACKSPACE; + // wrapper.find('input').trigger('keydown'); + // }); + // await asyncExpect(() => { + // expect(isOpen(wrapper)).toBeFalsy(); + // }, 0); }); describe('limit filtered item count', () => { @@ -191,7 +211,6 @@ describe('Cascader', () => { }); it('negative limit', async () => { - const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); const wrapper = mount(Cascader, { props: { options, showSearch: { filter, limit: -1 } }, sync: false, @@ -203,9 +222,6 @@ describe('Cascader', () => { await asyncExpect(() => { expect($$('.ant-cascader-menu-item').length).toBe(2); }, 0); - expect(errorSpy).toBeCalledWith( - "Warning: [antdv: Cascader] 'limit' of showSearch in Cascader should be positive number or false.", - ); }); }); }); diff --git a/components/cascader/demo/basic.vue b/components/cascader/demo/basic.vue index 244ab8c024..1f1f77ff5a 100644 --- a/components/cascader/demo/basic.vue +++ b/components/cascader/demo/basic.vue @@ -20,12 +20,8 @@ Cascade selection box for selecting province/city/district. diff --git a/components/cascader/demo/lazy.vue b/components/cascader/demo/lazy.vue index 12395d587a..daa36e205d 100644 --- a/components/cascader/demo/lazy.vue +++ b/components/cascader/demo/lazy.vue @@ -28,16 +28,11 @@ Load options lazily with `loadData`. diff --git a/components/cascader/demo/search.vue b/components/cascader/demo/search.vue index 27a1aacf59..778a3c426f 100644 --- a/components/cascader/demo/search.vue +++ b/components/cascader/demo/search.vue @@ -27,13 +27,9 @@ Search and select options directly. diff --git a/components/tree-select/demo/index.vue b/components/tree-select/demo/index.vue index 87fa104692..4a3b576466 100644 --- a/components/tree-select/demo/index.vue +++ b/components/tree-select/demo/index.vue @@ -7,6 +7,9 @@ + + + diff --git a/components/tree-select/demo/treeData.vue b/components/tree-select/demo/treeData.vue deleted file mode 100644 index f8865710ee..0000000000 --- a/components/tree-select/demo/treeData.vue +++ /dev/null @@ -1,74 +0,0 @@ - ---- -order: 2 -title: - zh-CN: 从数据直接生成 - en-US: Generate form tree data ---- - -## zh-CN - -使用 `treeData` 把 JSON 数据直接生成树结构。 - -## en-US - -The tree structure can be populated using `treeData` property. This is a quick and easy way to provide the tree content. - - - - - diff --git a/components/tree-select/demo/virtual-scroll.vue b/components/tree-select/demo/virtual-scroll.vue new file mode 100644 index 0000000000..f8c5eb8476 --- /dev/null +++ b/components/tree-select/demo/virtual-scroll.vue @@ -0,0 +1,73 @@ + +--- +order: 9 +title: + zh-CN: 虚拟滚动 + en-US: Virtual scroll +--- + +## zh-CN + +使用 `height` 属性则切换为虚拟滚动。 + +## en-US + +Use virtual list through `height` prop. + + + + diff --git a/components/tree-select/index.en-US.md b/components/tree-select/index.en-US.md index 27865f4c47..9b19e8ea04 100644 --- a/components/tree-select/index.en-US.md +++ b/components/tree-select/index.en-US.md @@ -47,6 +47,7 @@ Tree selection control. | treeDefaultExpandAll | Whether to expand all treeNodes by default | boolean | false | | | treeDefaultExpandedKeys | Default expanded treeNodes | string\[] \| number\[] | - | | | treeExpandedKeys(v-model) | Set expanded keys | string\[] \| number\[] | - | | +| treeLine | Show the line. Ref [Tree - showLine](/components/tree/#components-tree-demo-line) | boolean \| object | false | 3.0 | | treeNodeFilterProp | Will be used for filtering if `filterTreeNode` returns true | string | 'value' | | | treeNodeLabelProp | Will render as content of select | string | 'title' | | | value(v-model) | To set the current selected treeNode(s). | string\|string\[] | - | | diff --git a/components/tree-select/index.tsx b/components/tree-select/index.tsx index 3cd8f59991..2ec382e5bc 100644 --- a/components/tree-select/index.tsx +++ b/components/tree-select/index.tsx @@ -10,7 +10,7 @@ import VcTreeSelect, { import classNames from '../_util/classNames'; import initDefaultProps from '../_util/props-util/initDefaultProps'; import type { SizeType } from '../config-provider'; -import type { DefaultValueType, FieldNames } from '../vc-tree-select/interface'; +import type { FieldNames, Key } from '../vc-tree-select/interface'; import omit from '../_util/omit'; import PropTypes from '../_util/vue-types'; import useConfigInject from '../_util/hooks/useConfigInject'; @@ -21,6 +21,9 @@ import type { AntTreeNodeProps } from '../tree/Tree'; import { warning } from '../vc-util/warning'; import { flattenChildren } from '../_util/props-util'; import { useInjectFormItemContext } from '../form/FormItemContext'; +import type { BaseSelectRef } from '../vc-select'; +import type { BaseOptionType, DefaultOptionType } from '../vc-tree-select/TreeSelect'; +import type { TreeProps } from '../tree'; const getTransitionName = (rootPrefixCls: string, motion: string, transitionName?: string) => { if (transitionName !== undefined) { @@ -34,29 +37,42 @@ type RawValue = string | number; export interface LabeledValue { key?: string; value: RawValue; - label: any; + label?: any; } export type SelectValue = RawValue | RawValue[] | LabeledValue | LabeledValue[]; -export interface RefTreeSelectProps { - focus: () => void; - blur: () => void; +export type RefTreeSelectProps = BaseSelectRef; + +export function treeSelectProps< + ValueType = any, + OptionType extends BaseOptionType | DefaultOptionType = DefaultOptionType, +>() { + return { + ...omit(vcTreeSelectProps(), [ + 'showTreeIcon', + 'treeMotion', + 'inputIcon', + 'getInputElement', + 'treeLine', + 'customSlots', + ]), + suffixIcon: PropTypes.any, + size: { type: String as PropType }, + bordered: { type: Boolean, default: undefined }, + treeLine: { type: [Boolean, Object] as PropType, default: undefined }, + replaceFields: { type: Object as PropType }, + 'onUpdate:value': { type: Function as PropType<(value: any) => void> }, + 'onUpdate:treeExpandedKeys': { type: Function as PropType<(keys: Key[]) => void> }, + 'onUpdate:searchValue': { type: Function as PropType<(value: string) => void> }, + }; } -export const treeSelectProps = { - ...omit(vcTreeSelectProps(), ['showTreeIcon', 'treeMotion', 'inputIcon']), - suffixIcon: PropTypes.any, - size: { type: String as PropType }, - bordered: { type: Boolean, default: undefined }, - replaceFields: { type: Object as PropType }, -}; -export type TreeSelectProps = Partial>; +export type TreeSelectProps = Partial>>; const TreeSelect = defineComponent({ - TreeNode, name: 'ATreeSelect', inheritAttrs: false, - props: initDefaultProps(treeSelectProps, { + props: initDefaultProps(treeSelectProps(), { choiceTransitionName: '', listHeight: 256, treeIcon: false, @@ -135,18 +151,18 @@ const TreeSelect = defineComponent({ }, }); - const handleChange = (...args: any[]) => { + const handleChange: TreeSelectProps['onChange'] = (...args: any[]) => { emit('update:value', args[0]); emit('change', ...args); formItemContext.onFieldChange(); }; - const handleTreeExpand = (...args: any[]) => { - emit('update:treeExpandedKeys', args[0]); - emit('treeExpand', ...args); + const handleTreeExpand: TreeSelectProps['onTreeExpand'] = (keys: Key[]) => { + emit('update:treeExpandedKeys', keys); + emit('treeExpand', keys); }; - const handleSearch = (...args: any[]) => { - emit('update:searchValue', args[0]); - emit('search', ...args); + const handleSearch: TreeSelectProps['onSearch'] = (value: string) => { + emit('update:searchValue', value); + emit('search', value); }; const handleBlur = () => { emit('blur'); @@ -190,6 +206,10 @@ const TreeSelect = defineComponent({ 'removeIcon', 'clearIcon', 'switcherIcon', + 'bordered', + 'onUpdate:value', + 'onUpdate:treeExpandedKeys', + 'onUpdate:searchValue', ]); const mergedClassName = classNames( @@ -242,6 +262,11 @@ const TreeSelect = defineComponent({ }} {...otherProps} transitionName={transitionName.value} + customSlots={{ + ...slots, + treeCheckable: () => , + }} + maxTagPlaceholder={props.maxTagPlaceholder || slots.maxTagPlaceholder} /> ); }; diff --git a/components/tree-select/index.zh-CN.md b/components/tree-select/index.zh-CN.md index 9849f66ca3..d6a30becaf 100644 --- a/components/tree-select/index.zh-CN.md +++ b/components/tree-select/index.zh-CN.md @@ -48,6 +48,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Ax4DA0njr/TreeSelect.svg | treeDefaultExpandAll | 默认展开所有树节点 | boolean | false | | | treeDefaultExpandedKeys | 默认展开的树节点 | string\[] \| number\[] | - | | | treeExpandedKeys(v-model) | 设置展开的树节点 | string\[] \| number\[] | - | | +| treeLine | 是否展示线条样式,请参考 [Tree - showLine](/components/tree/#components-tree-demo-line) | boolean \| object | false | 3.0 | | treeNodeFilterProp | 输入项过滤对应的 treeNode 属性 | string | 'value' | | | treeNodeLabelProp | 作为显示的 prop 设置 | string | 'title' | | | value(v-model) | 指定当前选中的条目 | string/string\[] | - | | diff --git a/components/tree-select/style/index.less b/components/tree-select/style/index.less index e6672b5872..b2cf719f36 100644 --- a/components/tree-select/style/index.less +++ b/components/tree-select/style/index.less @@ -11,7 +11,7 @@ .@{tree-select-prefix-cls} { // ======================= Dropdown ======================= &-dropdown { - padding: @padding-xs (@padding-xs / 2) 0; + padding: @padding-xs (@padding-xs / 2); &-rtl { direction: rtl; @@ -24,8 +24,6 @@ align-items: stretch; .@{select-tree-prefix-cls}-treenode { - padding-bottom: @padding-xs; - .@{select-tree-prefix-cls}-node-content-wrapper { flex: auto; } diff --git a/components/tree-select/utils.tsx b/components/tree-select/utils.tsx deleted file mode 100644 index 0f16a65ef7..0000000000 --- a/components/tree-select/utils.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { flattenChildren, isValidElement } from '../_util/props-util'; - -export function convertChildrenToData(nodes: any[]): any[] { - return flattenChildren(nodes) - .map(node => { - if (!isValidElement(node) || !node.type) { - return null; - } - const { default: d, ...restSlot } = node.children || {}; - const children = d ? d() : []; - const { - key, - props: { value, ...restProps }, - } = node; - - const data = { - key, - value, - ...restProps, - }; - Object.keys(restSlot).forEach(p => { - if (typeof restSlot[p] === 'function') { - data[p] = <>{restSlot[p]()}; - } - }); - const childData = convertChildrenToData(children); - if (childData.length) { - data.children = childData; - } - - return data; - }) - .filter(data => data); -} diff --git a/components/tree/Tree.tsx b/components/tree/Tree.tsx index 9b4fc9b3f9..dc4fb1bc79 100644 --- a/components/tree/Tree.tsx +++ b/components/tree/Tree.tsx @@ -81,7 +81,10 @@ export interface AntTreeNodeDropEvent { export const treeProps = () => { return { ...vcTreeProps(), - showLine: { type: Boolean, default: undefined }, + showLine: { + type: [Boolean, Object] as PropType, + default: undefined, + }, /** 是否支持多选 */ multiple: { type: Boolean, default: undefined }, /** 是否自动展开父节点 */ diff --git a/components/tree/__tests__/__snapshots__/demo.test.js.snap b/components/tree/__tests__/__snapshots__/demo.test.js.snap index e8e81dd9d8..08104b45d0 100644 --- a/components/tree/__tests__/__snapshots__/demo.test.js.snap +++ b/components/tree/__tests__/__snapshots__/demo.test.js.snap @@ -13,11 +13,13 @@ exports[`renders ./components/tree/demo/accordion.vue correctly 1`] = `
-
+
+ parent 1
-
+
+ parent 2
@@ -42,22 +44,28 @@ exports[`renders ./components/tree/demo/basic.vue correctly 1`] = `
-
parent 1 +
+ parent 1
-
parent 1-0 +
+ parent 1-0
-
leaf +
+ leaf
-
leaf +
+ leaf
-
parent 1-1 +
+ parent 1-1
-
sss +
+ sss
@@ -81,39 +89,48 @@ exports[`renders ./components/tree/demo/context-menu.vue correctly 1`] = `
-
+
+ 0-0
-
+
+ 0-0-0
-
+
+ 0-0-0-0
-
+
+ 0-0-0-1
-
+
+ 0-0-0-2
-
+
+ 0-0-1
-
+
+ 0-0-1-0
-
+
+ 0-0-1-1
-
+
+ 0-0-1-2
@@ -138,15 +155,18 @@ exports[`renders ./components/tree/demo/customized-icon.vue correctly 1`] = `
-
+
+ parent 1
-
+
+ leaf
-
+
+ leaf
@@ -171,27 +191,33 @@ exports[`renders ./components/tree/demo/directory.vue correctly 1`] = `
-
+
+ parent 0
-
+
+ leaf 0-0
-
+
+ leaf 0-1
-
+
+ parent 1
-
+
+ leaf 1-0
-
+
+ leaf 1-1
@@ -216,16 +242,19 @@ exports[`renders ./components/tree/demo/draggable.vue correctly 1`] = `
-
- 0-0 +
+ + 0-0
-
- 0-1 +
+ + 0-1
-
- 0-2 +
+ + 0-2
@@ -249,15 +278,18 @@ exports[`renders ./components/tree/demo/dynamic.vue correctly 1`] = `
-
+
+ Expand to load
-
+
+ Expand to load
-
+
+ Tree Node
@@ -288,35 +320,43 @@ exports[`renders ./components/tree/demo/line.vue correctly 1`] = `
-
+
+ parent 1
-
+
+ parent 1-0
-
+
+ leaf
-
+
+
multiple line title
multiple line title
-
+
+ leaf
-
+
+ parent 1-1
-
+
+ parent 1-2
-
+
+ parent 2
@@ -342,22 +382,28 @@ exports[`renders ./components/tree/demo/replaceFields.vue correctly 1`] = `
-
parent 1 +
+ parent 1
-
张晨成 +
+ 张晨成
-
leaf +
+ leaf
-
leaf +
+ leaf
-
parent 1-1 +
+ parent 1-1
-
zcvc +
+ zcvc
@@ -382,15 +428,18 @@ exports[`renders ./components/tree/demo/search.vue correctly 1`] = `
-
+
+ 0-0
-
+
+ 0-1
-
+
+ 0-2
@@ -416,31 +465,38 @@ exports[`renders ./components/tree/demo/switcher-icon.vue correctly 1`] = `
-
+
+ parent 1
-
+
+ parent 1-0
-
+
+ leaf
-
+
+ leaf
-
+
+ leaf
-
+
+ parent 1-1
-
+
+ parent 1-2
@@ -465,34 +521,44 @@ exports[`renders ./components/tree/demo/virtual-scroll.vue correctly 1`] = `
-
0-0 +
+ 0-0
-
0-0-0 +
+ 0-0-0
-
0-0-0-0 +
+ 0-0-0-0
-
0-0-0-0-0 +
+ 0-0-0-0-0
-
0-0-0-0-1 +
+ 0-0-0-0-1
-
0-0-0-0-2 +
+ 0-0-0-0-2
-
0-0-0-0-3 +
+ 0-0-0-0-3
-
0-0-0-0-4 +
+ 0-0-0-0-4
-
0-0-0-0-5 +
+ 0-0-0-0-5
-
0-0-0-0-6 +
+ 0-0-0-0-6
diff --git a/components/tree/index.en-US.md b/components/tree/index.en-US.md index e3be9b5f43..a01a96dbb7 100644 --- a/components/tree/index.en-US.md +++ b/components/tree/index.en-US.md @@ -36,7 +36,7 @@ Almost anything can be represented in a tree structure. Examples include directo | selectedKeys(v-model) | (Controlled) Specifies the keys of the selected treeNodes | string\[] \| number\[] | - | | | showIcon | Shows the icon before a TreeNode's title. There is no default style; you must set a custom style for it if set to `true` | boolean | false | | | switcherIcon | customize collapse/expand icon of tree node | slot | - | | -| showLine | Shows a connecting line | boolean | false | | +| showLine | Shows a connecting line | boolean \| {showLeafIcon: boolean}(3.0+) | false | | | title | custom title | slot | | 2.0.0 | ### Events diff --git a/components/tree/index.zh-CN.md b/components/tree/index.zh-CN.md index 58c7f9ac5d..9b96591488 100644 --- a/components/tree/index.zh-CN.md +++ b/components/tree/index.zh-CN.md @@ -37,7 +37,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/Xh-oWqg9k/Tree.svg | selectedKeys(v-model) | (受控)设置选中的树节点 | string\[] \| number\[] | - | | | showIcon | 是否展示 TreeNode title 前的图标,没有默认样式,如设置为 true,需要自行定义图标相关样式 | boolean | false | | | switcherIcon | 自定义树节点的展开/折叠图标 | slot | - | | -| showLine | 是否展示连接线 | boolean | false | | +| showLine | 是否展示连接线 | boolean \| {showLeafIcon: boolean}(3.0+) | false | | | title | 自定义标题 | slot | | 2.0.0 | ### 事件 diff --git a/components/vc-cascader/Cascader.jsx b/components/vc-cascader/Cascader.jsx deleted file mode 100644 index d388c286f2..0000000000 --- a/components/vc-cascader/Cascader.jsx +++ /dev/null @@ -1,388 +0,0 @@ -import { getComponent, getSlot, hasProp, getEvents } from '../_util/props-util'; -import PropTypes from '../_util/vue-types'; -import Trigger from '../vc-trigger'; -import Menus from './Menus'; -import KeyCode from '../_util/KeyCode'; -import arrayTreeFilter from 'array-tree-filter'; -import shallowEqualArrays from 'shallow-equal/arrays'; -import BaseMixin from '../_util/BaseMixin'; -import { cloneElement } from '../_util/vnode'; -import { defineComponent } from 'vue'; -import isEqual from 'lodash-es/isEqual'; - -const BUILT_IN_PLACEMENTS = { - bottomLeft: { - points: ['tl', 'bl'], - offset: [0, 4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - topLeft: { - points: ['bl', 'tl'], - offset: [0, -4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - bottomRight: { - points: ['tr', 'br'], - offset: [0, 4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, - topRight: { - points: ['br', 'tr'], - offset: [0, -4], - overflow: { - adjustX: 1, - adjustY: 1, - }, - }, -}; - -export default defineComponent({ - name: 'Cascader', - mixins: [BaseMixin], - inheritAttrs: false, - // model: { - // prop: 'value', - // event: 'change', - // }, - props: { - value: PropTypes.array, - defaultValue: PropTypes.array, - options: PropTypes.array, - // onChange: PropTypes.func, - // onPopupVisibleChange: PropTypes.func, - popupVisible: PropTypes.looseBool, - disabled: PropTypes.looseBool.def(false), - transitionName: PropTypes.string.def(''), - popupClassName: PropTypes.string.def(''), - popupStyle: PropTypes.object.def(() => ({})), - popupPlacement: PropTypes.string.def('bottomLeft'), - prefixCls: PropTypes.string.def('rc-cascader'), - dropdownMenuColumnStyle: PropTypes.object, - builtinPlacements: PropTypes.object.def(BUILT_IN_PLACEMENTS), - loadData: PropTypes.func, - changeOnSelect: PropTypes.looseBool, - // onKeyDown: PropTypes.func, - expandTrigger: PropTypes.string.def('click'), - fieldNames: PropTypes.object.def(() => ({ - label: 'label', - value: 'value', - children: 'children', - })), - expandIcon: PropTypes.any, - loadingIcon: PropTypes.any, - getPopupContainer: PropTypes.func, - }, - data() { - let initialValue = []; - const { value, defaultValue, popupVisible } = this; - if (hasProp(this, 'value')) { - initialValue = value || []; - } else if (hasProp(this, 'defaultValue')) { - initialValue = defaultValue || []; - } - this.children = undefined; - // warning(!('filedNames' in props), - // '`filedNames` of Cascader is a typo usage and deprecated, please use `fieldNames` instead.'); - this.defaultFieldNames = { label: 'label', value: 'value', children: 'children' }; - return { - sPopupVisible: popupVisible, - sActiveValue: initialValue, - sValue: initialValue, - }; - }, - watch: { - value(val, oldValue) { - if (!shallowEqualArrays(val, oldValue)) { - const newValues = { - sValue: val || [], - }; - // allow activeValue diff from value - // https://github.com/ant-design/ant-design/issues/2767 - if (!hasProp(this, 'loadData')) { - newValues.sActiveValue = val || []; - } - this.setState(newValues); - } - }, - popupVisible(val) { - this.setState({ - sPopupVisible: val, - }); - }, - }, - methods: { - getPopupDOMNode() { - return this.trigger.getPopupDomNode(); - }, - getFieldName(name) { - const { defaultFieldNames, fieldNames } = this; - return fieldNames[name] || defaultFieldNames[name]; - }, - getFieldNames() { - return this.fieldNames; - }, - getCurrentLevelOptions() { - const { options = [], sActiveValue = [] } = this; - const result = arrayTreeFilter( - options, - (o, level) => isEqual(o[this.getFieldName('value')], sActiveValue[level]), - { childrenKeyName: this.getFieldName('children') }, - ); - if (result[result.length - 2]) { - return result[result.length - 2][this.getFieldName('children')]; - } - return [...options].filter(o => !o.disabled); - }, - getActiveOptions(activeValue) { - return arrayTreeFilter( - this.options || [], - (o, level) => isEqual(o[this.getFieldName('value')], activeValue[level]), - { childrenKeyName: this.getFieldName('children') }, - ); - }, - setPopupVisible(popupVisible) { - if (!hasProp(this, 'popupVisible')) { - this.setState({ sPopupVisible: popupVisible }); - } - // sync activeValue with value when panel open - if (popupVisible && !this.sPopupVisible) { - this.setState({ - sActiveValue: this.sValue, - }); - } - this.__emit('popupVisibleChange', popupVisible); - }, - handleChange(options, setProps, e) { - if (e.type !== 'keydown' || e.keyCode === KeyCode.ENTER) { - const value = options.map(o => o[this.getFieldName('value')]); - this.__emit('change', value, options); - this.setPopupVisible(setProps.visible); - } - }, - handlePopupVisibleChange(popupVisible) { - this.setPopupVisible(popupVisible); - }, - handleMenuSelect(targetOption, menuIndex, e) { - // Keep focused state for keyboard support - const triggerNode = this.trigger.getRootDomNode(); - if (triggerNode && triggerNode.focus) { - triggerNode.focus(); - } - const { changeOnSelect, loadData, expandTrigger } = this; - if (!targetOption || targetOption.disabled) { - return; - } - let { sActiveValue } = this; - sActiveValue = sActiveValue.slice(0, menuIndex + 1); - sActiveValue[menuIndex] = targetOption[this.getFieldName('value')]; - const activeOptions = this.getActiveOptions(sActiveValue); - if ( - targetOption.isLeaf === false && - !targetOption[this.getFieldName('children')] && - loadData - ) { - if (changeOnSelect) { - this.handleChange(activeOptions, { visible: true }, e); - } - this.setState({ sActiveValue }); - loadData(activeOptions); - return; - } - const newState = {}; - if ( - !targetOption[this.getFieldName('children')] || - !targetOption[this.getFieldName('children')].length - ) { - this.handleChange(activeOptions, { visible: false }, e); - // set value to activeValue when select leaf option - newState.sValue = sActiveValue; - // add e.type judgement to prevent `onChange` being triggered by mouseEnter - } else if (changeOnSelect && (e.type === 'click' || e.type === 'keydown')) { - if (expandTrigger === 'hover') { - this.handleChange(activeOptions, { visible: false }, e); - } else { - this.handleChange(activeOptions, { visible: true }, e); - } - // set value to activeValue on every select - newState.sValue = sActiveValue; - } - newState.sActiveValue = sActiveValue; - // not change the value by keyboard - if (hasProp(this, 'value') || (e.type === 'keydown' && e.keyCode !== KeyCode.ENTER)) { - delete newState.sValue; - } - this.setState(newState); - }, - handleItemDoubleClick() { - const { changeOnSelect } = this.$props; - if (changeOnSelect) { - this.setPopupVisible(false); - } - }, - handleKeyDown(e) { - const children = this.children; - // https://github.com/ant-design/ant-design/issues/6717 - // Don't bind keyboard support when children specify the onKeyDown - if (children) { - const keydown = getEvents(children).onKeydown; - if (keydown) { - keydown(e); - return; - } - } - const activeValue = [...this.sActiveValue]; - const currentLevel = activeValue.length - 1 < 0 ? 0 : activeValue.length - 1; - const currentOptions = this.getCurrentLevelOptions(); - const currentIndex = currentOptions - .map(o => o[this.getFieldName('value')]) - .findIndex(val => isEqual(activeValue[currentLevel], val)); - if ( - e.keyCode !== KeyCode.DOWN && - e.keyCode !== KeyCode.UP && - e.keyCode !== KeyCode.LEFT && - e.keyCode !== KeyCode.RIGHT && - e.keyCode !== KeyCode.ENTER && - e.keyCode !== KeyCode.SPACE && - e.keyCode !== KeyCode.BACKSPACE && - e.keyCode !== KeyCode.ESC && - e.keyCode !== KeyCode.TAB - ) { - return; - } - // Press any keys above to reopen menu - if ( - !this.sPopupVisible && - e.keyCode !== KeyCode.BACKSPACE && - e.keyCode !== KeyCode.LEFT && - e.keyCode !== KeyCode.RIGHT && - e.keyCode !== KeyCode.ESC && - e.keyCode !== KeyCode.TAB - ) { - this.setPopupVisible(true); - return; - } - if (e.keyCode === KeyCode.DOWN || e.keyCode === KeyCode.UP) { - e.preventDefault(); - let nextIndex = currentIndex; - if (nextIndex !== -1) { - if (e.keyCode === KeyCode.DOWN) { - nextIndex += 1; - nextIndex = nextIndex >= currentOptions.length ? 0 : nextIndex; - } else { - nextIndex -= 1; - nextIndex = nextIndex < 0 ? currentOptions.length - 1 : nextIndex; - } - } else { - nextIndex = 0; - } - activeValue[currentLevel] = currentOptions[nextIndex][this.getFieldName('value')]; - } else if (e.keyCode === KeyCode.LEFT || e.keyCode === KeyCode.BACKSPACE) { - e.preventDefault(); - activeValue.splice(activeValue.length - 1, 1); - } else if (e.keyCode === KeyCode.RIGHT) { - e.preventDefault(); - if ( - currentOptions[currentIndex] && - currentOptions[currentIndex][this.getFieldName('children')] - ) { - activeValue.push( - currentOptions[currentIndex][this.getFieldName('children')][0][ - this.getFieldName('value') - ], - ); - } - } else if (e.keyCode === KeyCode.ESC || e.keyCode === KeyCode.TAB) { - this.setPopupVisible(false); - return; - } - if (!activeValue || activeValue.length === 0) { - this.setPopupVisible(false); - } - const activeOptions = this.getActiveOptions(activeValue); - const targetOption = activeOptions[activeOptions.length - 1]; - this.handleMenuSelect(targetOption, activeOptions.length - 1, e); - this.__emit('keydown', e); - }, - saveTrigger(node) { - this.trigger = node; - }, - }, - - render() { - const { - $props, - sActiveValue, - handleMenuSelect, - sPopupVisible, - handlePopupVisibleChange, - handleKeyDown, - } = this; - const { - prefixCls, - transitionName, - popupClassName, - options = [], - disabled, - builtinPlacements, - popupPlacement, - ...restProps - } = $props; - // Did not show popup when there is no options - let menus =
; - let emptyMenuClassName = ''; - if (options && options.length > 0) { - const loadingIcon = getComponent(this, 'loadingIcon'); - const expandIcon = getComponent(this, 'expandIcon') || '>'; - const menusProps = { - ...$props, - ...this.$attrs, - fieldNames: this.getFieldNames(), - defaultFieldNames: this.defaultFieldNames, - activeValue: sActiveValue, - visible: sPopupVisible, - loadingIcon, - expandIcon, - onSelect: handleMenuSelect, - onItemDoubleClick: this.handleItemDoubleClick, - }; - menus = ; - } else { - emptyMenuClassName = ` ${prefixCls}-menus-empty`; - } - const triggerProps = { - ...restProps, - ...this.$attrs, - disabled, - popupPlacement, - builtinPlacements, - popupTransitionName: transitionName, - action: disabled ? [] : ['click'], - popupVisible: disabled ? false : sPopupVisible, - prefixCls: `${prefixCls}-menus`, - popupClassName: popupClassName + emptyMenuClassName, - popup: menus, - onPopupVisibleChange: handlePopupVisibleChange, - ref: this.saveTrigger, - }; - const children = getSlot(this); - this.children = children; - return ( - - {children && - cloneElement(children[0], { - onKeydown: handleKeyDown, - tabindex: disabled ? undefined : 0, - })} - - ); - }, -}); diff --git a/components/vc-cascader/Cascader.tsx b/components/vc-cascader/Cascader.tsx new file mode 100644 index 0000000000..a00e450d32 --- /dev/null +++ b/components/vc-cascader/Cascader.tsx @@ -0,0 +1,566 @@ +import { computed, defineComponent, ref, toRef, toRefs, watchEffect } from 'vue'; +import type { CSSProperties, ExtractPropTypes, PropType, Ref } from 'vue'; +import type { BaseSelectRef, BaseSelectProps } from '../vc-select'; +import type { DisplayValueType, Placement } from '../vc-select/BaseSelect'; +import { baseSelectPropsWithoutPrivate } from '../vc-select/BaseSelect'; +import omit from '../_util/omit'; +import type { Key, VueNode } from '../_util/type'; +import PropTypes from '../_util/vue-types'; +import { initDefaultProps } from '../_util/props-util'; +import useId from '../vc-select/hooks/useId'; +import useMergedState from '../_util/hooks/useMergedState'; +import { fillFieldNames, toPathKey, toPathKeys } from './utils/commonUtil'; +import useEntities from './hooks/useEntities'; +import useSearchConfig from './hooks/useSearchConfig'; +import useSearchOptions from './hooks/useSearchOptions'; +import useMissingValues from './hooks/useMissingValues'; +import { formatStrategyValues, toPathOptions } from './utils/treeUtil'; +import { conductCheck } from '../vc-tree/utils/conductUtil'; +import useDisplayValues from './hooks/useDisplayValues'; +import { useProvideCascader } from './context'; +import OptionList from './OptionList'; +import { BaseSelect } from '../vc-select'; +import devWarning from '../vc-util/devWarning'; + +export interface ShowSearchType { + filter?: (inputValue: string, options: OptionType[], fieldNames: FieldNames) => boolean; + render?: (arg?: { + inputValue: string; + path: OptionType[]; + prefixCls: string; + fieldNames: FieldNames; + }) => any; + sort?: (a: OptionType[], b: OptionType[], inputValue: string, fieldNames: FieldNames) => number; + matchInputWidth?: boolean; + limit?: number | false; +} + +export interface FieldNames { + label?: string; + value?: string; + children?: string; +} + +export interface InternalFieldNames extends Required { + key: string; +} + +export type SingleValueType = (string | number)[]; + +export type ValueType = SingleValueType | SingleValueType[]; + +export interface BaseOptionType { + disabled?: boolean; + [name: string]: any; +} +export interface DefaultOptionType extends BaseOptionType { + label?: any; + value?: string | number | null; + children?: DefaultOptionType[]; +} + +function baseCascaderProps() { + return { + ...omit(baseSelectPropsWithoutPrivate(), ['tokenSeparators', 'mode', 'showSearch']), + // MISC + id: String, + prefixCls: String, + fieldNames: Object as PropType, + children: Array as PropType, + + // Value + value: { type: [String, Number, Array] as PropType }, + defaultValue: { type: [String, Number, Array] as PropType }, + changeOnSelect: { type: Boolean, default: undefined }, + onChange: Function as PropType< + (value: ValueType, selectedOptions?: OptionType[] | OptionType[][]) => void + >, + displayRender: Function as PropType< + (opt: { labels: string[]; selectedOptions?: OptionType[] }) => any + >, + checkable: { type: Boolean, default: undefined }, + + // Search + showSearch: { + type: [Boolean, Object] as PropType>, + default: undefined as boolean | ShowSearchType, + }, + searchValue: String, + onSearch: Function as PropType<(value: string) => void>, + + // Trigger + expandTrigger: String as PropType<'hover' | 'click'>, + + // Options + options: Array as PropType, + /** @private Internal usage. Do not use in your production. */ + dropdownPrefixCls: String, + loadData: Function as PropType<(selectOptions: OptionType[]) => void>, + + // Open + /** @deprecated Use `open` instead */ + popupVisible: { type: Boolean, default: undefined }, + + /** @deprecated Use `dropdownClassName` instead */ + popupClassName: String, + dropdownClassName: String, + dropdownMenuColumnStyle: { + type: Object as PropType, + default: undefined as CSSProperties, + }, + + /** @deprecated Use `dropdownStyle` instead */ + popupStyle: { type: Object as PropType, default: undefined as CSSProperties }, + dropdownStyle: { type: Object as PropType, default: undefined as CSSProperties }, + + /** @deprecated Use `placement` instead */ + popupPlacement: String as PropType, + placement: String as PropType, + + /** @deprecated Use `onDropdownVisibleChange` instead */ + onPopupVisibleChange: Function as PropType<(open: boolean) => void>, + onDropdownVisibleChange: Function as PropType<(open: boolean) => void>, + + // Icon + expandIcon: PropTypes.any, + loadingIcon: PropTypes.any, + }; +} + +export type BaseCascaderProps = Partial>>; + +type OnSingleChange = (value: SingleValueType, selectOptions: OptionType[]) => void; +type OnMultipleChange = ( + value: SingleValueType[], + selectOptions: OptionType[][], +) => void; + +export function singleCascaderProps() { + return { + ...baseCascaderProps(), + checkable: Boolean as PropType, + onChange: Function as PropType>, + }; +} + +export type SingleCascaderProps = Partial>>; + +export function multipleCascaderProps() { + return { + ...baseCascaderProps(), + checkable: Boolean as PropType, + onChange: Function as PropType>, + }; +} + +export type MultipleCascaderProps = Partial< + ExtractPropTypes> +>; + +export function internalCascaderProps() { + return { + ...baseCascaderProps(), + onChange: Function as PropType< + (value: ValueType, selectOptions: OptionType[] | OptionType[][]) => void + >, + customSlots: Object as PropType>, + }; +} + +export type CascaderProps = Partial>>; +export type CascaderRef = Omit; + +function isMultipleValue(value: ValueType): value is SingleValueType[] { + return Array.isArray(value) && Array.isArray(value[0]); +} + +function toRawValues(value: ValueType): SingleValueType[] { + if (!value) { + return []; + } + + if (isMultipleValue(value)) { + return value; + } + + return value.length === 0 ? [] : [value]; +} + +export default defineComponent({ + name: 'Cascader', + inheritAttrs: false, + props: initDefaultProps(internalCascaderProps(), {}), + setup(props, { attrs, expose, slots }) { + const mergedId = useId(toRef(props, 'id')); + const multiple = computed(() => !!props.checkable); + + // =========================== Values =========================== + const [rawValues, setRawValues] = useMergedState>( + props.defaultValue, + { + value: computed(() => props.value), + postState: toRawValues, + }, + ); + + // ========================= FieldNames ========================= + const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames)); + + // =========================== Option =========================== + const mergedOptions = computed(() => props.options || []); + + // Only used in multiple mode, this fn will not call in single mode + const pathKeyEntities = useEntities(mergedOptions, mergedFieldNames); + + /** Convert path key back to value format */ + const getValueByKeyPath = (pathKeys: Key[]): SingleValueType[] => { + const ketPathEntities = pathKeyEntities.value; + + return pathKeys.map(pathKey => { + const { nodes } = ketPathEntities[pathKey]; + + return nodes.map(node => node[mergedFieldNames.value.value]); + }); + }; + + // =========================== Search =========================== + const [mergedSearchValue, setSearchValue] = useMergedState('', { + value: computed(() => props.searchValue), + postState: search => search || '', + }); + + const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => { + setSearchValue(searchText); + + if (info.source !== 'blur' && props.onSearch) { + props.onSearch(searchText); + } + }; + + const { showSearch: mergedShowSearch, searchConfig: mergedSearchConfig } = useSearchConfig( + toRef(props, 'showSearch'), + ); + + const searchOptions = useSearchOptions( + mergedSearchValue, + mergedOptions, + mergedFieldNames, + computed(() => props.dropdownPrefixCls || props.prefixCls), + mergedSearchConfig, + toRef(props, 'changeOnSelect'), + ); + + // =========================== Values =========================== + const missingValuesInfo = useMissingValues(mergedOptions, mergedFieldNames, rawValues); + + // Fill `rawValues` with checked conduction values + const [checkedValues, halfCheckedValues, missingCheckedValues] = [ + ref([]), + ref([]), + ref([]), + ]; + watchEffect(() => { + const [existValues, missingValues] = missingValuesInfo.value; + + if (!multiple.value || !rawValues.value.length) { + [checkedValues.value, halfCheckedValues.value, missingCheckedValues.value] = [ + existValues, + [], + missingValues, + ]; + return; + } + + const keyPathValues = toPathKeys(existValues); + const ketPathEntities = pathKeyEntities.value; + + const { checkedKeys, halfCheckedKeys } = conductCheck(keyPathValues, true, ketPathEntities); + + // Convert key back to value cells + [checkedValues.value, halfCheckedValues.value, missingCheckedValues.value] = [ + getValueByKeyPath(checkedKeys), + getValueByKeyPath(halfCheckedKeys), + missingValues, + ]; + }); + + const deDuplicatedValues = computed(() => { + const checkedKeys = toPathKeys(checkedValues.value); + const deduplicateKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value); + return [...missingCheckedValues.value, ...getValueByKeyPath(deduplicateKeys)]; + }); + + const displayValues = useDisplayValues( + deDuplicatedValues, + mergedOptions, + mergedFieldNames, + multiple, + toRef(props, 'displayRender'), + ); + + // =========================== Change =========================== + const triggerChange = (nextValues: ValueType) => { + setRawValues(nextValues); + + // Save perf if no need trigger event + if (props.onChange) { + const nextRawValues = toRawValues(nextValues); + + const valueOptions = nextRawValues.map(valueCells => + toPathOptions(valueCells, mergedOptions.value, mergedFieldNames.value).map( + valueOpt => valueOpt.option, + ), + ); + + const triggerValues = multiple.value ? nextRawValues : nextRawValues[0]; + const triggerOptions = multiple.value ? valueOptions : valueOptions[0]; + + props.onChange(triggerValues, triggerOptions); + } + }; + + // =========================== Select =========================== + const onInternalSelect = (valuePath: SingleValueType) => { + if (!multiple.value) { + triggerChange(valuePath); + } else { + // Prepare conduct required info + const pathKey = toPathKey(valuePath); + const checkedPathKeys = toPathKeys(checkedValues.value); + const halfCheckedPathKeys = toPathKeys(halfCheckedValues.value); + + const existInChecked = checkedPathKeys.includes(pathKey); + const existInMissing = missingCheckedValues.value.some( + valueCells => toPathKey(valueCells) === pathKey, + ); + + // Do update + let nextCheckedValues = checkedValues.value; + let nextMissingValues = missingCheckedValues.value; + + if (existInMissing && !existInChecked) { + // Missing value only do filter + nextMissingValues = missingCheckedValues.value.filter( + valueCells => toPathKey(valueCells) !== pathKey, + ); + } else { + // Update checked key first + const nextRawCheckedKeys = existInChecked + ? checkedPathKeys.filter(key => key !== pathKey) + : [...checkedPathKeys, pathKey]; + + // Conduction by selected or not + let checkedKeys: Key[]; + if (existInChecked) { + ({ checkedKeys } = conductCheck( + nextRawCheckedKeys, + { checked: false, halfCheckedKeys: halfCheckedPathKeys }, + pathKeyEntities.value, + )); + } else { + ({ checkedKeys } = conductCheck(nextRawCheckedKeys, true, pathKeyEntities.value)); + } + + // Roll up to parent level keys + const deDuplicatedKeys = formatStrategyValues(checkedKeys, pathKeyEntities.value); + nextCheckedValues = getValueByKeyPath(deDuplicatedKeys); + } + + triggerChange([...nextMissingValues, ...nextCheckedValues]); + } + }; + + // Display Value change logic + const onDisplayValuesChange: BaseSelectProps['onDisplayValuesChange'] = (_, info) => { + if (info.type === 'clear') { + triggerChange([]); + return; + } + + // Cascader do not support `add` type. Only support `remove` + const { valueCells } = info.values[0] as DisplayValueType & { valueCells: SingleValueType }; + onInternalSelect(valueCells); + }; + + // ============================ Open ============================ + if (process.env.NODE_ENV !== 'production') { + watchEffect(() => { + devWarning( + !props.onPopupVisibleChange, + 'Cascader', + '`popupVisibleChange` is deprecated. Please use `dropdownVisibleChange` instead.', + ); + devWarning( + props.popupVisible === undefined, + 'Cascader', + '`popupVisible` is deprecated. Please use `open` instead.', + ); + devWarning( + props.popupClassName === undefined, + 'Cascader', + '`popupClassName` is deprecated. Please use `dropdownClassName` instead.', + ); + devWarning( + props.popupPlacement === undefined, + 'Cascader', + '`popupPlacement` is deprecated. Please use `placement` instead.', + ); + devWarning( + props.popupStyle === undefined, + 'Cascader', + '`popupStyle` is deprecated. Please use `dropdownStyle` instead.', + ); + }); + } + + const mergedOpen = computed(() => (props.open !== undefined ? props.open : props.popupVisible)); + + const mergedDropdownClassName = computed(() => props.dropdownClassName || props.popupClassName); + + const mergedDropdownStyle = computed(() => props.dropdownStyle || props.popupStyle || {}); + + const mergedPlacement = computed(() => props.placement || props.popupPlacement); + + const onInternalDropdownVisibleChange = (nextVisible: boolean) => { + props.onDropdownVisibleChange?.(nextVisible); + props.onPopupVisibleChange?.(nextVisible); + }; + const { + changeOnSelect, + checkable, + dropdownPrefixCls, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle, + customSlots, + } = toRefs(props); + useProvideCascader({ + options: mergedOptions, + fieldNames: mergedFieldNames, + values: checkedValues, + halfValues: halfCheckedValues, + changeOnSelect, + onSelect: onInternalSelect, + checkable, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + expandIcon, + loadingIcon, + dropdownMenuColumnStyle, + customSlots, + }); + const selectRef = ref(); + + expose({ + focus() { + selectRef.value?.focus(); + }, + blur() { + selectRef.value?.blur(); + }, + scrollTo(arg) { + selectRef.value?.scrollTo(arg); + }, + } as BaseSelectRef); + + const pickProps = computed(() => { + return omit(props, [ + 'id', + 'prefixCls', + 'fieldNames', + + // Value + 'defaultValue', + 'value', + 'changeOnSelect', + 'onChange', + 'displayRender', + 'checkable', + + // Search + 'searchValue', + 'onSearch', + 'showSearch', + + // Trigger + 'expandTrigger', + + // Options + 'options', + 'dropdownPrefixCls', + 'loadData', + + // Open + 'popupVisible', + 'open', + + 'popupClassName', + 'dropdownClassName', + 'dropdownMenuColumnStyle', + + 'popupPlacement', + 'placement', + + 'onDropdownVisibleChange', + 'onPopupVisibleChange', + + // Icon + 'expandIcon', + 'loadingIcon', + 'customSlots', + + // Children + 'children', + ]); + }); + return () => { + const emptyOptions = !(mergedSearchValue.value ? searchOptions.value : mergedOptions.value) + .length; + + const dropdownStyle: CSSProperties = + // Search to match width + (mergedSearchValue.value && mergedSearchConfig.value.matchInputWidth) || + // Empty keep the width + emptyOptions + ? {} + : { + minWidth: 'auto', + }; + return ( + slots.default?.()} + v-slots={slots} + /> + ); + }; + }, +}); diff --git a/components/vc-cascader/Menus.jsx b/components/vc-cascader/Menus.jsx deleted file mode 100644 index a3f9ace7fc..0000000000 --- a/components/vc-cascader/Menus.jsx +++ /dev/null @@ -1,182 +0,0 @@ -import { getComponent, findDOMNode } from '../_util/props-util'; -import PropTypes from '../_util/vue-types'; -import arrayTreeFilter from 'array-tree-filter'; -import BaseMixin from '../_util/BaseMixin'; -import isEqual from 'lodash-es/isEqual'; - -export default { - name: 'CascaderMenus', - mixins: [BaseMixin], - inheritAttrs: false, - props: { - value: PropTypes.array.def([]), - activeValue: PropTypes.array.def([]), - options: PropTypes.array, - prefixCls: PropTypes.string.def('rc-cascader-menus'), - expandTrigger: PropTypes.string.def('click'), - // onSelect: PropTypes.func, - visible: PropTypes.looseBool.def(false), - dropdownMenuColumnStyle: PropTypes.object, - defaultFieldNames: PropTypes.object, - fieldNames: PropTypes.object, - expandIcon: PropTypes.any, - loadingIcon: PropTypes.any, - }, - data() { - this.menuItems = {}; - return {}; - }, - watch: { - visible(val) { - if (val) { - this.$nextTick(() => { - this.scrollActiveItemToView(); - }); - } - }, - }, - mounted() { - this.$nextTick(() => { - this.scrollActiveItemToView(); - }); - }, - methods: { - getFieldName(name) { - const { fieldNames, defaultFieldNames } = this.$props; - // 防止只设置单个属性的名字 - return fieldNames[name] || defaultFieldNames[name]; - }, - getOption(option, menuIndex) { - const { prefixCls, expandTrigger } = this; - const loadingIcon = getComponent(this, 'loadingIcon'); - const expandIcon = getComponent(this, 'expandIcon'); - const onSelect = e => { - this.__emit('select', option, menuIndex, e); - }; - const onItemDoubleClick = e => { - this.__emit('itemDoubleClick', option, menuIndex, e); - }; - const key = option[this.getFieldName('value')]; - let expandProps = { - onClick: onSelect, - onDblclick: onItemDoubleClick, - }; - let menuItemCls = `${prefixCls}-menu-item`; - let expandIconNode = null; - const hasChildren = - option[this.getFieldName('children')] && option[this.getFieldName('children')].length > 0; - if (hasChildren || option.isLeaf === false) { - menuItemCls += ` ${prefixCls}-menu-item-expand`; - if (!option.loading) { - expandIconNode = {expandIcon}; - } - } - if (expandTrigger === 'hover' && (hasChildren || option.isLeaf === false)) { - expandProps = { - onMouseenter: this.delayOnSelect.bind(this, onSelect), - onMouseleave: this.delayOnSelect.bind(this), - onClick: onSelect, - }; - } - if (this.isActiveOption(option, menuIndex)) { - menuItemCls += ` ${prefixCls}-menu-item-active`; - expandProps.ref = this.saveMenuItem(menuIndex); - } - if (option.disabled) { - menuItemCls += ` ${prefixCls}-menu-item-disabled`; - } - let loadingIconNode = null; - if (option.loading) { - menuItemCls += ` ${prefixCls}-menu-item-loading`; - loadingIconNode = loadingIcon || null; - } - let title = ''; - if (option.title) { - title = option.title; - } else if (typeof option[this.getFieldName('label')] === 'string') { - title = option[this.getFieldName('label')]; - } - return ( - - ); - }, - - getActiveOptions(values) { - const activeValue = values || this.activeValue; - const options = this.options; - return arrayTreeFilter( - options, - (o, level) => isEqual(o[this.getFieldName('value')], activeValue[level]), - { childrenKeyName: this.getFieldName('children') }, - ); - }, - - getShowOptions() { - const { options } = this; - const result = this.getActiveOptions() - .map(activeOption => activeOption[this.getFieldName('children')]) - .filter(activeOption => !!activeOption); - result.unshift(options); - return result; - }, - - delayOnSelect(onSelect, ...args) { - if (this.delayTimer) { - clearTimeout(this.delayTimer); - this.delayTimer = null; - } - if (typeof onSelect === 'function') { - this.delayTimer = setTimeout(() => { - onSelect(args); - this.delayTimer = null; - }, 150); - } - }, - - scrollActiveItemToView() { - // scroll into view - const optionsLength = this.getShowOptions().length; - for (let i = 0; i < optionsLength; i++) { - const itemComponent = this.menuItems[i]; - if (itemComponent) { - const target = findDOMNode(itemComponent); - target.parentNode.scrollTop = target.offsetTop; - } - } - }, - - isActiveOption(option, menuIndex) { - const { activeValue = [] } = this; - return isEqual(activeValue[menuIndex], option[this.getFieldName('value')]); - }, - saveMenuItem(index) { - return node => { - this.menuItems[index] = node; - }; - }, - }, - - render() { - const { prefixCls, dropdownMenuColumnStyle } = this; - return ( -
- {this.getShowOptions().map((options, menuIndex) => ( -
    - {options.map(option => this.getOption(option, menuIndex))} -
- ))} -
- ); - }, -}; diff --git a/components/vc-cascader/OptionList/Checkbox.tsx b/components/vc-cascader/OptionList/Checkbox.tsx new file mode 100644 index 0000000000..17cc643b04 --- /dev/null +++ b/components/vc-cascader/OptionList/Checkbox.tsx @@ -0,0 +1,44 @@ +import type { MouseEventHandler } from '../../_util/EventInterface'; +import { useInjectCascader } from '../context'; + +export interface CheckboxProps { + prefixCls: string; + checked?: boolean; + halfChecked?: boolean; + disabled?: boolean; + onClick?: MouseEventHandler; +} + +export default function Checkbox({ + prefixCls, + checked, + halfChecked, + disabled, + onClick, +}: CheckboxProps) { + const { customSlots, checkable } = useInjectCascader(); + + const mergedCheckable = checkable.value !== false ? customSlots.value.checkable : checkable.value; + const customCheckbox = + typeof mergedCheckable === 'function' + ? mergedCheckable() + : typeof mergedCheckable === 'boolean' + ? null + : mergedCheckable; + return ( + + {customCheckbox} + + ); +} +Checkbox.props = ['prefixCls', 'checked', 'halfChecked', 'disabled', 'onClick']; +Checkbox.displayName = 'Checkbox'; +Checkbox.inheritAttrs = false; diff --git a/components/vc-cascader/OptionList/Column.tsx b/components/vc-cascader/OptionList/Column.tsx new file mode 100644 index 0000000000..da8c6b430b --- /dev/null +++ b/components/vc-cascader/OptionList/Column.tsx @@ -0,0 +1,176 @@ +import { isLeaf, toPathKey } from '../utils/commonUtil'; +import Checkbox from './Checkbox'; +import type { DefaultOptionType, SingleValueType } from '../Cascader'; +import { SEARCH_MARK } from '../hooks/useSearchOptions'; +import type { Key } from '../../_util/type'; +import { useInjectCascader } from '../context'; + +export interface ColumnProps { + prefixCls: string; + multiple?: boolean; + options: DefaultOptionType[]; + /** Current Column opened item key */ + activeValue?: Key; + /** The value path before current column */ + prevValuePath: Key[]; + onToggleOpen: (open: boolean) => void; + onSelect: (valuePath: SingleValueType, leaf: boolean) => void; + onActive: (valuePath: SingleValueType) => void; + checkedSet: Set; + halfCheckedSet: Set; + loadingKeys: Key[]; + isSelectable: (option: DefaultOptionType) => boolean; +} + +export default function Column({ + prefixCls, + multiple, + options, + activeValue, + prevValuePath, + onToggleOpen, + onSelect, + onActive, + checkedSet, + halfCheckedSet, + loadingKeys, + isSelectable, +}: ColumnProps) { + const menuPrefixCls = `${prefixCls}-menu`; + const menuItemPrefixCls = `${prefixCls}-menu-item`; + + const { + fieldNames, + changeOnSelect, + expandTrigger, + expandIcon: expandIconRef, + loadingIcon: loadingIconRef, + dropdownMenuColumnStyle, + customSlots, + } = useInjectCascader(); + const expandIcon = expandIconRef.value ?? customSlots.value.expandIcon?.(); + const loadingIcon = loadingIconRef.value ?? customSlots.value.loadingIcon?.(); + + const hoverOpen = expandTrigger.value === 'hover'; + // ============================ Render ============================ + return ( + + ); +} +Column.props = [ + 'prefixCls', + 'multiple', + 'options', + 'activeValue', + 'prevValuePath', + 'onToggleOpen', + 'onSelect', + 'onActive', + 'checkedSet', + 'halfCheckedSet', + 'loadingKeys', + 'isSelectable', +]; +Column.displayName = 'Column'; +Column.inheritAttrs = false; diff --git a/components/vc-cascader/OptionList/index.tsx b/components/vc-cascader/OptionList/index.tsx new file mode 100644 index 0000000000..68e566ea4c --- /dev/null +++ b/components/vc-cascader/OptionList/index.tsx @@ -0,0 +1,228 @@ +/* eslint-disable default-case */ +import Column from './Column'; +import type { DefaultOptionType, SingleValueType } from '../Cascader'; +import { isLeaf, toPathKey, toPathKeys, toPathValueStr } from '../utils/commonUtil'; +import useActive from './useActive'; +import useKeyboard from './useKeyboard'; +import { toPathOptions } from '../utils/treeUtil'; +import { computed, defineComponent, ref, shallowRef, watchEffect } from 'vue'; +import { useBaseProps } from '../../vc-select'; +import { useInjectCascader } from '../context'; +import type { Key } from '../../_util/type'; +import type { EventHandler } from '../../_util/EventInterface'; + +export default defineComponent({ + name: 'OptionList', + inheritAttrs: false, + setup(_props, context) { + const { attrs, slots } = context; + const baseProps = useBaseProps(); + const containerRef = ref(); + const rtl = computed(() => baseProps.direction === 'rtl'); + const { + options, + values, + halfValues, + fieldNames, + changeOnSelect, + onSelect, + searchOptions, + dropdownPrefixCls, + loadData, + expandTrigger, + customSlots, + } = useInjectCascader(); + + const mergedPrefixCls = computed(() => dropdownPrefixCls.value || baseProps.prefixCls); + + // ========================= loadData ========================= + const loadingKeys = shallowRef([]); + const internalLoadData = (valueCells: Key[]) => { + // Do not load when search + if (!loadData.value || baseProps.searchValue) { + return; + } + + const optionList = toPathOptions(valueCells, options.value, fieldNames.value); + const rawOptions = optionList.map(({ option }) => option); + const lastOption = rawOptions[rawOptions.length - 1]; + + if (lastOption && !isLeaf(lastOption, fieldNames.value)) { + const pathKey = toPathKey(valueCells); + + loadingKeys.value = [...loadingKeys.value, pathKey]; + loadData.value(rawOptions); + } + }; + + watchEffect(() => { + if (loadingKeys.value.length) { + loadingKeys.value.forEach(loadingKey => { + const valueStrCells = toPathValueStr(loadingKey); + const optionList = toPathOptions( + valueStrCells, + options.value, + fieldNames.value, + true, + ).map(({ option }) => option); + const lastOption = optionList[optionList.length - 1]; + + if ( + !lastOption || + lastOption[fieldNames.value.children] || + isLeaf(lastOption, fieldNames.value) + ) { + loadingKeys.value = loadingKeys.value.filter(key => key !== loadingKey); + } + }); + } + }); + + // ========================== Values ========================== + const checkedSet = computed(() => new Set(toPathKeys(values.value))); + const halfCheckedSet = computed(() => new Set(toPathKeys(halfValues.value))); + + // ====================== Accessibility ======================= + const [activeValueCells, setActiveValueCells] = useActive(); + + // =========================== Path =========================== + const onPathOpen = (nextValueCells: Key[]) => { + setActiveValueCells(nextValueCells); + + // Trigger loadData + internalLoadData(nextValueCells); + }; + + const isSelectable = (option: DefaultOptionType) => { + const { disabled } = option; + + const isMergedLeaf = isLeaf(option, fieldNames.value); + return !disabled && (isMergedLeaf || changeOnSelect.value || baseProps.multiple); + }; + + const onPathSelect = (valuePath: SingleValueType, leaf: boolean, fromKeyboard = false) => { + onSelect(valuePath); + + if ( + !baseProps.multiple && + (leaf || (changeOnSelect.value && (expandTrigger.value === 'hover' || fromKeyboard))) + ) { + baseProps.toggleOpen(false); + } + }; + + // ========================== Option ========================== + const mergedOptions = computed(() => { + if (baseProps.searchValue) { + return searchOptions.value; + } + + return options.value; + }); + + // ========================== Column ========================== + const optionColumns = computed(() => { + const optionList = [{ options: mergedOptions.value }]; + let currentList = mergedOptions.value; + for (let i = 0; i < activeValueCells.value.length; i += 1) { + const activeValueCell = activeValueCells.value[i]; + const currentOption = currentList.find( + option => option[fieldNames.value.value] === activeValueCell, + ); + + const subOptions = currentOption?.[fieldNames.value.children]; + if (!subOptions?.length) { + break; + } + + currentList = subOptions; + optionList.push({ options: subOptions }); + } + + return optionList; + }); + + // ========================= Keyboard ========================= + const onKeyboardSelect = (selectValueCells: SingleValueType, option: DefaultOptionType) => { + if (isSelectable(option)) { + onPathSelect(selectValueCells, isLeaf(option, fieldNames.value), true); + } + }; + + useKeyboard( + context, + mergedOptions, + fieldNames, + activeValueCells, + onPathOpen, + containerRef, + onKeyboardSelect, + ); + const onListMouseDown: EventHandler = event => { + event.preventDefault(); + }; + return () => { + // ========================== Render ========================== + const { + notFoundContent = slots.notFoundContent?.() || customSlots.value.notFoundContent?.(), + multiple, + toggleOpen, + } = baseProps; + // >>>>> Empty + const isEmpty = !optionColumns.value[0]?.options?.length; + + const emptyList: DefaultOptionType[] = [ + { + [fieldNames.value.label as 'label']: notFoundContent, + [fieldNames.value.value as 'value']: '__EMPTY__', + disabled: true, + }, + ]; + const columnProps = { + ...attrs, + multiple: !isEmpty && multiple, + onSelect: onPathSelect, + onActive: onPathOpen, + onToggleOpen: toggleOpen, + checkedSet: checkedSet.value, + halfCheckedSet: halfCheckedSet.value, + loadingKeys: loadingKeys.value, + isSelectable, + }; + + // >>>>> Columns + const mergedOptionColumns = isEmpty ? [{ options: emptyList }] : optionColumns.value; + + const columnNodes = mergedOptionColumns.map((col, index) => { + const prevValuePath = activeValueCells.value.slice(0, index); + const activeValue = activeValueCells.value[index]; + + return ( + + ); + }); + return ( +
+ {columnNodes} +
+ ); + }; + }, +}); diff --git a/components/vc-cascader/OptionList/useActive.ts b/components/vc-cascader/OptionList/useActive.ts new file mode 100644 index 0000000000..40903e9494 --- /dev/null +++ b/components/vc-cascader/OptionList/useActive.ts @@ -0,0 +1,31 @@ +import { useInjectCascader } from '../context'; +import type { Ref } from 'vue'; +import { watch } from 'vue'; +import { useBaseProps } from '../../vc-select'; +import type { Key } from '../../_util/type'; +import useState from '../../_util/hooks/useState'; + +/** + * Control the active open options path. + */ +export default (): [Ref, (activeValueCells: Key[]) => void] => { + const baseProps = useBaseProps(); + const { values } = useInjectCascader(); + + // Record current dropdown active options + // This also control the open status + const [activeValueCells, setActiveValueCells] = useState([]); + + watch( + () => baseProps.open, + () => { + if (baseProps.open && !baseProps.multiple) { + const firstValueCells = values.value[0]; + setActiveValueCells(firstValueCells || []); + } + }, + { immediate: true }, + ); + + return [activeValueCells, setActiveValueCells]; +}; diff --git a/components/vc-cascader/OptionList/useKeyboard.ts b/components/vc-cascader/OptionList/useKeyboard.ts new file mode 100644 index 0000000000..fe161d00e0 --- /dev/null +++ b/components/vc-cascader/OptionList/useKeyboard.ts @@ -0,0 +1,188 @@ +import type { RefOptionListProps } from '../../vc-select/OptionList'; +import type { Key } from 'ant-design-vue/es/_util/type'; +import type { Ref, SetupContext } from 'vue'; +import { computed, ref, watchEffect } from 'vue'; +import type { DefaultOptionType, InternalFieldNames, SingleValueType } from '../Cascader'; +import { toPathKey } from '../utils/commonUtil'; +import { useBaseProps } from '../../vc-select'; +import KeyCode from '../../_util/KeyCode'; + +export default ( + context: SetupContext, + options: Ref, + fieldNames: Ref, + activeValueCells: Ref, + setActiveValueCells: (activeValueCells: Key[]) => void, + containerRef: Ref, + onKeyBoardSelect: (valueCells: SingleValueType, option: DefaultOptionType) => void, +) => { + const baseProps = useBaseProps(); + const rtl = computed(() => baseProps.direction === 'rtl'); + const [validActiveValueCells, lastActiveIndex, lastActiveOptions] = [ + ref([]), + ref(), + ref([]), + ]; + watchEffect(() => { + let activeIndex = -1; + let currentOptions = options.value; + + const mergedActiveIndexes: number[] = []; + const mergedActiveValueCells: Key[] = []; + + const len = activeValueCells.value.length; + // Fill validate active value cells and index + for (let i = 0; i < len; i += 1) { + // Mark the active index for current options + const nextActiveIndex = currentOptions.findIndex( + option => option[fieldNames.value.value] === activeValueCells.value[i], + ); + + if (nextActiveIndex === -1) { + break; + } + + activeIndex = nextActiveIndex; + mergedActiveIndexes.push(activeIndex); + mergedActiveValueCells.push(activeValueCells.value[i]); + + currentOptions = currentOptions[activeIndex][fieldNames.value.children]; + } + + // Fill last active options + let activeOptions = options.value; + for (let i = 0; i < mergedActiveIndexes.length - 1; i += 1) { + activeOptions = activeOptions[mergedActiveIndexes[i]][fieldNames.value.children]; + } + + [validActiveValueCells.value, lastActiveIndex.value, lastActiveOptions.value] = [ + mergedActiveValueCells, + activeIndex, + activeOptions, + ]; + }); + + // Update active value cells and scroll to target element + const internalSetActiveValueCells = (next: Key[]) => { + setActiveValueCells(next); + + const ele = containerRef.value?.querySelector(`li[data-path-key="${toPathKey(next)}"]`); + ele?.scrollIntoView?.({ block: 'nearest' }); + }; + + // Same options offset + const offsetActiveOption = (offset: number) => { + const len = lastActiveOptions.value.length; + + let currentIndex = lastActiveIndex.value; + if (currentIndex === -1 && offset < 0) { + currentIndex = len; + } + + for (let i = 0; i < len; i += 1) { + currentIndex = (currentIndex + offset + len) % len; + const option = lastActiveOptions.value[currentIndex]; + + if (option && !option.disabled) { + const value = option[fieldNames.value.value]; + const nextActiveCells = validActiveValueCells.value.slice(0, -1).concat(value); + internalSetActiveValueCells(nextActiveCells); + return; + } + } + }; + + // Different options offset + const prevColumn = () => { + if (validActiveValueCells.value.length > 1) { + const nextActiveCells = validActiveValueCells.value.slice(0, -1); + internalSetActiveValueCells(nextActiveCells); + } else { + baseProps.toggleOpen(false); + } + }; + + const nextColumn = () => { + const nextOptions: DefaultOptionType[] = + lastActiveOptions.value[lastActiveIndex.value]?.[fieldNames.value.children] || []; + + const nextOption = nextOptions.find(option => !option.disabled); + + if (nextOption) { + const nextActiveCells = [...validActiveValueCells.value, nextOption[fieldNames.value.value]]; + internalSetActiveValueCells(nextActiveCells); + } + }; + + context.expose({ + // scrollTo: treeRef.current?.scrollTo, + onKeydown: event => { + const { which } = event; + + switch (which) { + // >>> Arrow keys + case KeyCode.UP: + case KeyCode.DOWN: { + let offset = 0; + if (which === KeyCode.UP) { + offset = -1; + } else if (which === KeyCode.DOWN) { + offset = 1; + } + + if (offset !== 0) { + offsetActiveOption(offset); + } + + break; + } + + case KeyCode.LEFT: { + if (rtl.value) { + nextColumn(); + } else { + prevColumn(); + } + break; + } + + case KeyCode.RIGHT: { + if (rtl.value) { + prevColumn(); + } else { + nextColumn(); + } + break; + } + + case KeyCode.BACKSPACE: { + if (!baseProps.searchValue) { + prevColumn(); + } + break; + } + + // >>> Select + case KeyCode.ENTER: { + if (validActiveValueCells.value.length) { + onKeyBoardSelect( + validActiveValueCells.value, + lastActiveOptions.value[lastActiveIndex.value], + ); + } + break; + } + + // >>> Close + case KeyCode.ESC: { + baseProps.toggleOpen(false); + + if (open) { + event.stopPropagation(); + } + } + } + }, + onKeyup: () => {}, + } as RefOptionListProps); +}; diff --git a/components/vc-cascader/assets/index.less b/components/vc-cascader/assets/index.less deleted file mode 100644 index f48b1c7539..0000000000 --- a/components/vc-cascader/assets/index.less +++ /dev/null @@ -1,168 +0,0 @@ -.effect() { - animation-duration: 0.3s; - animation-fill-mode: both; - transform-origin: 0 0; -} - -.rc-cascader { - font-size: 12px; - &-menus { - font-size: 12px; - overflow: hidden; - background: #fff; - position: absolute; - border: 1px solid #d9d9d9; - border-radius: 6px; - box-shadow: 0 0 4px rgba(0, 0, 0, 0.17); - white-space: nowrap; - - &-hidden { - display: none; - } - - &.slide-up-enter, - &.slide-up-appear { - .effect(); - opacity: 0; - animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); - animation-play-state: paused; - } - - &.slide-up-leave { - .effect(); - opacity: 1; - animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); - animation-play-state: paused; - } - - &.slide-up-enter.slide-up-enter-active&-placement-bottomLeft, - &.slide-up-appear.slide-up-appear-active&-placement-bottomLeft { - animation-name: SlideUpIn; - animation-play-state: running; - } - - &.slide-up-enter.slide-up-enter-active&-placement-topLeft, - &.slide-up-appear.slide-up-appear-active&-placement-topLeft { - animation-name: SlideDownIn; - animation-play-state: running; - } - - &.slide-up-leave.slide-up-leave-active&-placement-bottomLeft { - animation-name: SlideUpOut; - animation-play-state: running; - } - - &.slide-up-leave.slide-up-leave-active&-placement-topLeft { - animation-name: SlideDownOut; - animation-play-state: running; - } - } - &-menu { - display: inline-block; - width: 100px; - height: 192px; - list-style: none; - margin: 0; - padding: 0; - border-right: 1px solid #e9e9e9; - overflow: auto; - &:last-child { - border-right: 0; - } - } - &-menu-item { - height: 32px; - line-height: 32px; - padding: 0 16px; - cursor: pointer; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; - transition: all 0.3s ease; - position: relative; - &:hover { - background: tint(#2db7f5, 90%); - } - &-disabled { - cursor: not-allowed; - color: #ccc; - &:hover { - background: transparent; - } - } - &-loading:after { - position: absolute; - right: 12px; - content: 'loading'; - color: #aaa; - font-style: italic; - } - &-active { - background: tint(#2db7f5, 80%); - &:hover { - background: tint(#2db7f5, 80%); - } - } - &-expand { - position: relative; - &:after { - content: '>'; - font-size: 12px; - color: #999; - position: absolute; - right: 16px; - line-height: 32px; - } - } - } -} - -@keyframes SlideUpIn { - 0% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(0.8); - } - 100% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } -} -@keyframes SlideUpOut { - 0% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } - 100% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(0.8); - } -} - -@keyframes SlideDownIn { - 0% { - opacity: 0; - transform-origin: 0% 100%; - transform: scaleY(0.8); - } - 100% { - opacity: 1; - transform-origin: 0% 100%; - transform: scaleY(1); - } -} -@keyframes SlideDownOut { - 0% { - opacity: 1; - transform-origin: 0% 100%; - transform: scaleY(1); - } - 100% { - opacity: 0; - transform-origin: 0% 100%; - transform: scaleY(0.8); - } -} diff --git a/components/vc-cascader/context.ts b/components/vc-cascader/context.ts new file mode 100644 index 0000000000..f1ab3cd44e --- /dev/null +++ b/components/vc-cascader/context.ts @@ -0,0 +1,36 @@ +import type { CSSProperties, InjectionKey, Ref } from 'vue'; +import { inject, provide } from 'vue'; +import type { VueNode } from '../_util/type'; +import type { + BaseCascaderProps, + InternalFieldNames, + DefaultOptionType, + SingleValueType, +} from './Cascader'; + +export interface CascaderContextProps { + options: Ref; + fieldNames: Ref; + values: Ref; + halfValues: Ref; + changeOnSelect: Ref; + onSelect: (valuePath: SingleValueType) => void; + checkable: Ref; + searchOptions: Ref; + dropdownPrefixCls?: Ref; + loadData: Ref<(selectOptions: DefaultOptionType[]) => void>; + expandTrigger: Ref<'hover' | 'click'>; + expandIcon: Ref; + loadingIcon: Ref; + dropdownMenuColumnStyle: Ref; + customSlots: Ref>; +} + +const CascaderContextKey: InjectionKey = Symbol('CascaderContextKey'); +export const useProvideCascader = (props: CascaderContextProps) => { + provide(CascaderContextKey, props); +}; + +export const useInjectCascader = () => { + return inject(CascaderContextKey); +}; diff --git a/components/vc-cascader/hooks/useDisplayValues.ts b/components/vc-cascader/hooks/useDisplayValues.ts new file mode 100644 index 0000000000..2ae150151e --- /dev/null +++ b/components/vc-cascader/hooks/useDisplayValues.ts @@ -0,0 +1,60 @@ +import { toPathOptions } from '../utils/treeUtil'; +import type { + DefaultOptionType, + SingleValueType, + BaseCascaderProps, + InternalFieldNames, +} from '../Cascader'; +import { toPathKey } from '../utils/commonUtil'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import { isValidElement } from '../../_util/props-util'; +import { cloneElement } from '../../_util/vnode'; + +export default ( + rawValues: Ref, + options: Ref, + fieldNames: Ref, + multiple: Ref, + displayRender: Ref, +) => { + return computed(() => { + const mergedDisplayRender = + displayRender.value || + // Default displayRender + (({ labels }) => { + const mergedLabels = multiple.value ? labels.slice(-1) : labels; + const SPLIT = ' / '; + + if (mergedLabels.every(label => ['string', 'number'].includes(typeof label))) { + return mergedLabels.join(SPLIT); + } + + // If exist non-string value, use VueNode instead + return mergedLabels.reduce((list, label, index) => { + const keyedLabel = isValidElement(label) ? cloneElement(label, { key: index }) : label; + + if (index === 0) { + return [keyedLabel]; + } + + return [...list, SPLIT, keyedLabel]; + }, []); + }); + + return rawValues.value.map(valueCells => { + const valueOptions = toPathOptions(valueCells, options.value, fieldNames.value); + + const label = mergedDisplayRender({ + labels: valueOptions.map(({ option, value }) => option?.[fieldNames.value.label] ?? value), + selectedOptions: valueOptions.map(({ option }) => option), + }); + + return { + label, + value: toPathKey(valueCells), + valueCells, + }; + }); + }); +}; diff --git a/components/vc-cascader/hooks/useEntities.ts b/components/vc-cascader/hooks/useEntities.ts new file mode 100644 index 0000000000..5dbf14a18f --- /dev/null +++ b/components/vc-cascader/hooks/useEntities.ts @@ -0,0 +1,36 @@ +import { convertDataToEntities } from '../../vc-tree/utils/treeUtil'; +import type { DataEntity } from '../../vc-tree/interface'; +import type { DefaultOptionType, InternalFieldNames } from '../Cascader'; +import { VALUE_SPLIT } from '../utils/commonUtil'; +import type { Ref } from 'vue'; +import { computed } from 'vue'; + +export interface OptionsInfo { + keyEntities: Record; + pathKeyEntities: Record; +} + +/** Lazy parse options data into conduct-able info to avoid perf issue in single mode */ +export default (options: Ref, fieldNames: Ref) => { + const entities = computed(() => { + return ( + convertDataToEntities(options.value as any, { + fieldNames: fieldNames.value, + initWrapper: wrapper => ({ + ...wrapper, + pathKeyEntities: {}, + }), + processEntity: (entity, wrapper: any) => { + const pathKey = entity.nodes.map(node => node[fieldNames.value.value]).join(VALUE_SPLIT); + + wrapper.pathKeyEntities[pathKey] = entity; + + // Overwrite origin key. + // this is very hack but we need let conduct logic work with connect path + entity.key = pathKey; + }, + }) as any + ).pathKeyEntities; + }); + return entities; +}; diff --git a/components/vc-cascader/hooks/useMissingValues.ts b/components/vc-cascader/hooks/useMissingValues.ts new file mode 100644 index 0000000000..e704bf622f --- /dev/null +++ b/components/vc-cascader/hooks/useMissingValues.ts @@ -0,0 +1,26 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader'; +import { toPathOptions } from '../utils/treeUtil'; + +export default ( + options: Ref, + fieldNames: Ref, + rawValues: Ref, +) => { + return computed(() => { + const missingValues: SingleValueType[] = []; + const existsValues: SingleValueType[] = []; + + rawValues.value.forEach(valueCell => { + const pathOptions = toPathOptions(valueCell, options.value, fieldNames.value); + if (pathOptions.every(opt => opt.option)) { + existsValues.push(valueCell); + } else { + missingValues.push(valueCell); + } + }); + + return [existsValues, missingValues]; + }); +}; diff --git a/components/vc-cascader/hooks/useSearchConfig.ts b/components/vc-cascader/hooks/useSearchConfig.ts new file mode 100644 index 0000000000..950d29c00f --- /dev/null +++ b/components/vc-cascader/hooks/useSearchConfig.ts @@ -0,0 +1,41 @@ +import type { BaseCascaderProps, ShowSearchType } from '../Cascader'; +import type { Ref } from 'vue'; +import { ref, watchEffect } from 'vue'; +import { warning } from '../../vc-util/warning'; + +// Convert `showSearch` to unique config +export default function useSearchConfig(showSearch?: Ref) { + const mergedShowSearch = ref(false); + const mergedSearchConfig = ref({}); + watchEffect(() => { + if (!showSearch.value) { + mergedShowSearch.value = false; + mergedSearchConfig.value = {}; + return; + } + + let searchConfig: ShowSearchType = { + matchInputWidth: true, + limit: 50, + }; + + if (showSearch.value && typeof showSearch.value === 'object') { + searchConfig = { + ...searchConfig, + ...showSearch.value, + }; + } + + if (searchConfig.limit <= 0) { + delete searchConfig.limit; + + if (process.env.NODE_ENV !== 'production') { + warning(false, "'limit' of showSearch should be positive number or false."); + } + } + mergedShowSearch.value = true; + mergedSearchConfig.value = searchConfig; + return; + }); + return { showSearch: mergedShowSearch, searchConfig: mergedSearchConfig }; +} diff --git a/components/vc-cascader/hooks/useSearchOptions.ts b/components/vc-cascader/hooks/useSearchOptions.ts new file mode 100644 index 0000000000..6e0c11e871 --- /dev/null +++ b/components/vc-cascader/hooks/useSearchOptions.ts @@ -0,0 +1,76 @@ +import type { Ref } from 'vue'; +import { computed } from 'vue'; +import type { DefaultOptionType, ShowSearchType, InternalFieldNames } from '../Cascader'; + +export const SEARCH_MARK = '__rc_cascader_search_mark__'; + +const defaultFilter: ShowSearchType['filter'] = (search, options, { label }) => + options.some(opt => String(opt[label]).toLowerCase().includes(search.toLowerCase())); + +const defaultRender: ShowSearchType['render'] = ({ path, fieldNames }) => + path.map(opt => opt[fieldNames.label]).join(' / '); + +export default ( + search: Ref, + options: Ref, + fieldNames: Ref, + prefixCls: Ref, + config: Ref, + changeOnSelect: Ref, +) => { + return computed(() => { + const { filter = defaultFilter, render = defaultRender, limit = 50, sort } = config.value; + const filteredOptions: DefaultOptionType[] = []; + if (!search.value) { + return []; + } + + function dig(list: DefaultOptionType[], pathOptions: DefaultOptionType[]) { + list.forEach(option => { + // Perf saving when `sort` is disabled and `limit` is provided + if (!sort && limit > 0 && filteredOptions.length >= limit) { + return; + } + + const connectedPathOptions = [...pathOptions, option]; + const children = option[fieldNames.value.children]; + + // If current option is filterable + if ( + // If is leaf option + !children || + // If is changeOnSelect + changeOnSelect.value + ) { + if (filter(search.value, connectedPathOptions, { label: fieldNames.value.label })) { + filteredOptions.push({ + ...option, + [fieldNames.value.label as 'label']: render({ + inputValue: search.value, + path: connectedPathOptions, + prefixCls: prefixCls.value, + fieldNames: fieldNames.value, + }), + [SEARCH_MARK]: connectedPathOptions, + }); + } + } + + if (children) { + dig(option[fieldNames.value.children] as DefaultOptionType[], connectedPathOptions); + } + }); + } + + dig(options.value, []); + + // Do sort + if (sort) { + filteredOptions.sort((a, b) => { + return sort(a[SEARCH_MARK], b[SEARCH_MARK], search.value, fieldNames.value); + }); + } + + return limit > 0 ? filteredOptions.slice(0, limit as number) : filteredOptions; + }); +}; diff --git a/components/vc-cascader/index.js b/components/vc-cascader/index.js deleted file mode 100644 index 4bd1fc80af..0000000000 --- a/components/vc-cascader/index.js +++ /dev/null @@ -1,3 +0,0 @@ -// based on rc-cascader 0.17.4 -import Cascader from './Cascader'; -export default Cascader; diff --git a/components/vc-cascader/index.tsx b/components/vc-cascader/index.tsx new file mode 100644 index 0000000000..67c3d98d5f --- /dev/null +++ b/components/vc-cascader/index.tsx @@ -0,0 +1,12 @@ +// rc-cascader@3.0.0-alpha.6 +import Cascader, { internalCascaderProps as cascaderProps } from './Cascader'; + +export type { + CascaderProps, + FieldNames, + ShowSearchType, + DefaultOptionType, + BaseOptionType, +} from './Cascader'; +export { cascaderProps }; +export default Cascader; diff --git a/components/vc-cascader/utils/commonUtil.ts b/components/vc-cascader/utils/commonUtil.ts new file mode 100644 index 0000000000..df470a6d29 --- /dev/null +++ b/components/vc-cascader/utils/commonUtil.ts @@ -0,0 +1,35 @@ +import type { + DefaultOptionType, + FieldNames, + InternalFieldNames, + SingleValueType, +} from '../Cascader'; + +export const VALUE_SPLIT = '__RC_CASCADER_SPLIT__'; + +export function toPathKey(value: SingleValueType) { + return value.join(VALUE_SPLIT); +} + +export function toPathKeys(value: SingleValueType[]) { + return value.map(toPathKey); +} + +export function toPathValueStr(pathKey: string) { + return pathKey.split(VALUE_SPLIT); +} + +export function fillFieldNames(fieldNames?: FieldNames): InternalFieldNames { + const { label, value, children } = fieldNames || {}; + const val = value || 'value'; + return { + label: label || 'label', + value: val, + key: val, + children: children || 'children', + }; +} + +export function isLeaf(option: DefaultOptionType, fieldNames: FieldNames) { + return option.isLeaf ?? !option[fieldNames.children]?.length; +} diff --git a/components/vc-cascader/utils/treeUtil.ts b/components/vc-cascader/utils/treeUtil.ts new file mode 100644 index 0000000000..dbfd0bc974 --- /dev/null +++ b/components/vc-cascader/utils/treeUtil.ts @@ -0,0 +1,54 @@ +import type { Key } from '../../_util/type'; +import type { SingleValueType, DefaultOptionType, InternalFieldNames } from '../Cascader'; +import type { OptionsInfo } from '../hooks/useEntities'; + +export function formatStrategyValues( + pathKeys: Key[], + keyPathEntities: OptionsInfo['pathKeyEntities'], +) { + const valueSet = new Set(pathKeys); + + return pathKeys.filter(key => { + const entity = keyPathEntities[key]; + const parent = entity ? entity.parent : null; + + if (parent && !parent.node.disabled && valueSet.has(parent.key)) { + return false; + } + return true; + }); +} + +export function toPathOptions( + valueCells: SingleValueType, + options: DefaultOptionType[], + fieldNames: InternalFieldNames, + // Used for loadingKeys which saved loaded keys as string + stringMode = false, +) { + let currentList = options; + const valueOptions: { + value: SingleValueType[number]; + index: number; + option: DefaultOptionType; + }[] = []; + + for (let i = 0; i < valueCells.length; i += 1) { + const valueCell = valueCells[i]; + const foundIndex = currentList?.findIndex(option => { + const val = option[fieldNames.value]; + return stringMode ? String(val) === String(valueCell) : val === valueCell; + }); + const foundOption = foundIndex !== -1 ? currentList?.[foundIndex] : null; + + valueOptions.push({ + value: foundOption?.[fieldNames.value] ?? valueCell, + index: foundIndex, + option: foundOption, + }); + + currentList = foundOption?.[fieldNames.children]; + } + + return valueOptions; +} diff --git a/components/vc-overflow/Overflow.tsx b/components/vc-overflow/Overflow.tsx index 51ab2e966a..12e84f25be 100644 --- a/components/vc-overflow/Overflow.tsx +++ b/components/vc-overflow/Overflow.tsx @@ -307,7 +307,7 @@ const Overflow = defineComponent({ let restNode = () => null; const restContextProps = { order: displayRest ? mergedDisplayCount.value : Number.MAX_SAFE_INTEGER, - className: `${itemPrefixCls.value}-rest`, + className: `${itemPrefixCls.value} ${itemPrefixCls.value}-rest`, registerSize: registerOverflowSize, display: displayRest, }; diff --git a/components/vc-select/BaseSelect.tsx b/components/vc-select/BaseSelect.tsx new file mode 100644 index 0000000000..c7911321f5 --- /dev/null +++ b/components/vc-select/BaseSelect.tsx @@ -0,0 +1,895 @@ +import { getSeparatedContent } from './utils/valueUtil'; +import type { RefTriggerProps } from './SelectTrigger'; +import SelectTrigger from './SelectTrigger'; +import type { RefSelectorProps } from './Selector'; +import Selector from './Selector'; +import useSelectTriggerControl from './hooks/useSelectTriggerControl'; +import useDelayReset from './hooks/useDelayReset'; +import TransBtn from './TransBtn'; +import useLock from './hooks/useLock'; +import type { BaseSelectContextProps } from './hooks/useBaseProps'; +import { useProvideBaseSelectProps } from './hooks/useBaseProps'; +import type { Key, VueNode } from '../_util/type'; +import type { + FocusEventHandler, + KeyboardEventHandler, + MouseEventHandler, +} from '../_util/EventInterface'; +import type { ScrollTo } from '../vc-virtual-list/List'; +import { + computed, + defineComponent, + getCurrentInstance, + onBeforeUnmount, + onMounted, + provide, + ref, + toRefs, + watch, + watchEffect, +} from 'vue'; +import type { CSSProperties, ExtractPropTypes, PropType, VNode } from 'vue'; +import PropTypes from '../_util/vue-types'; +import { initDefaultProps, isValidElement } from '../_util/props-util'; +import isMobile from '../vc-util/isMobile'; +import KeyCode from '../_util/KeyCode'; +import { toReactive } from '../_util/toReactive'; +import classNames from '../_util/classNames'; +import createRef from '../_util/createRef'; +import type { BaseOptionType } from './Select'; +import useInjectLegacySelectContext from '../vc-tree-select/LegacyContext'; +import { cloneElement } from '../_util/vnode'; + +const DEFAULT_OMIT_PROPS = [ + 'value', + 'onChange', + 'removeIcon', + 'placeholder', + 'autofocus', + 'maxTagCount', + 'maxTagTextLength', + 'maxTagPlaceholder', + 'choiceTransitionName', + 'onInputKeyDown', + 'onPopupScroll', + 'tabindex', + 'OptionList', + 'notFoundContent', +] as const; + +export type RenderNode = VueNode | ((props: any) => VueNode); + +export type RenderDOMFunc = (props: any) => HTMLElement; + +export type Mode = 'multiple' | 'tags' | 'combobox'; + +export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; + +export type RawValueType = string | number; + +export interface RefOptionListProps { + onKeydown: KeyboardEventHandler; + onKeyup: KeyboardEventHandler; + scrollTo?: (index: number) => void; +} + +export type CustomTagProps = { + label: any; + value: any; + disabled: boolean; + onClose: (event?: MouseEvent) => void; + closable: boolean; + option: BaseOptionType; +}; + +export interface DisplayValueType { + key?: Key; + value?: RawValueType; + label?: any; + disabled?: boolean; + option?: BaseOptionType; +} + +export type BaseSelectRef = { + focus: () => void; + blur: () => void; + scrollTo: ScrollTo; +}; + +const baseSelectPrivateProps = () => { + return { + prefixCls: String, + id: String, + omitDomProps: Array as PropType, + + // >>> Value + displayValues: Array as PropType, + onDisplayValuesChange: Function as PropType< + ( + values: DisplayValueType[], + info: { + type: 'add' | 'remove' | 'clear'; + values: DisplayValueType[]; + }, + ) => void + >, + + // >>> Active + /** Current dropdown list active item string value */ + activeValue: String, + /** Link search input with target element */ + activeDescendantId: String, + onActiveValueChange: Function as PropType<(value: string | null) => void>, + + // >>> Search + searchValue: String, + /** Trigger onSearch, return false to prevent trigger open event */ + onSearch: Function as PropType< + ( + searchValue: string, + info: { + source: + | 'typing' //User typing + | 'effect' // Code logic trigger + | 'submit' // tag mode only + | 'blur'; // Not trigger event + }, + ) => void + >, + /** Trigger when search text match the `tokenSeparators`. Will provide split content */ + onSearchSplit: Function as PropType<(words: string[]) => void>, + maxLength: Number, + + OptionList: PropTypes.any, + + /** Tell if provided `options` is empty */ + emptyOptions: Boolean, + }; +}; + +export type DropdownObject = { + menuNode?: VueNode; + props?: Record; +}; + +export type DropdownRender = (opt?: DropdownObject) => VueNode; +export const baseSelectPropsWithoutPrivate = () => { + return { + showSearch: { type: Boolean, default: undefined }, + tagRender: { type: Function as PropType<(props: CustomTagProps) => any> }, + direction: { type: String as PropType<'ltr' | 'rtl'> }, + + // MISC + tabindex: Number, + autofocus: Boolean, + notFoundContent: PropTypes.any, + placeholder: PropTypes.any, + onClear: Function as PropType<() => void>, + + choiceTransitionName: String, + + // >>> Mode + mode: String as PropType, + + // >>> Status + disabled: { type: Boolean, default: undefined }, + loading: { type: Boolean, default: undefined }, + + // >>> Open + open: { type: Boolean, default: undefined }, + defaultOpen: { type: Boolean, default: undefined }, + onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> }, + + // >>> Customize Input + /** @private Internal usage. Do not use in your production. */ + getInputElement: { type: Function as PropType<() => any> }, + /** @private Internal usage. Do not use in your production. */ + getRawInputElement: { type: Function as PropType<() => any> }, + + // >>> Selector + maxTagTextLength: Number, + maxTagCount: { type: [String, Number] as PropType }, + maxTagPlaceholder: PropTypes.any, + + // >>> Search + tokenSeparators: { type: Array as PropType }, + + // >>> Icons + allowClear: { type: Boolean, default: undefined }, + showArrow: { type: Boolean, default: undefined }, + inputIcon: PropTypes.any, + /** Clear all icon */ + clearIcon: PropTypes.any, + /** Selector remove icon */ + removeIcon: PropTypes.any, + + // >>> Dropdown + animation: String, + transitionName: String, + dropdownStyle: { type: Object as PropType }, + dropdownClassName: String, + dropdownMatchSelectWidth: { + type: [Boolean, Number] as PropType, + default: undefined, + }, + dropdownRender: { type: Function as PropType }, + dropdownAlign: PropTypes.any, + placement: { + type: String as PropType, + }, + getPopupContainer: { type: Function as PropType }, + + // >>> Focus + showAction: { type: Array as PropType<('focus' | 'click')[]> }, + onBlur: { type: Function as PropType<(e: FocusEvent) => void> }, + onFocus: { type: Function as PropType<(e: FocusEvent) => void> }, + + // >>> Rest Events + onKeyup: Function as PropType<(e: KeyboardEvent) => void>, + onKeydown: Function as PropType<(e: KeyboardEvent) => void>, + onMousedown: Function as PropType<(e: MouseEvent) => void>, + onPopupScroll: Function as PropType<(e: UIEvent) => void>, + onInputKeyDown: Function as PropType<(e: KeyboardEvent) => void>, + onMouseenter: Function as PropType<(e: MouseEvent) => void>, + onMouseleave: Function as PropType<(e: MouseEvent) => void>, + onClick: Function as PropType<(e: MouseEvent) => void>, + }; +}; +const baseSelectProps = () => { + return { + ...baseSelectPrivateProps(), + ...baseSelectPropsWithoutPrivate(), + }; +}; + +export type BaseSelectPrivateProps = Partial< + ExtractPropTypes> +>; + +export type BaseSelectProps = Partial>>; + +export type BaseSelectPropsWithoutPrivate = Omit; + +export function isMultiple(mode: Mode) { + return mode === 'tags' || mode === 'multiple'; +} + +export default defineComponent({ + name: 'BaseSelect', + inheritAttrs: false, + props: initDefaultProps(baseSelectProps(), { showAction: [], notFoundContent: 'Not Found' }), + setup(props, { attrs, expose, slots }) { + const multiple = computed(() => isMultiple(props.mode)); + + const mergedShowSearch = computed(() => + props.showSearch !== undefined + ? props.showSearch + : multiple.value || props.mode === 'combobox', + ); + const mobile = ref(false); + onMounted(() => { + mobile.value = isMobile(); + }); + const legacyTreeSelectContext = useInjectLegacySelectContext(); + // ============================== Refs ============================== + const containerRef = ref(null); + const selectorDomRef = createRef(); + const triggerRef = ref(null); + const selectorRef = ref(null); + const listRef = ref(null); + + /** Used for component focused management */ + const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); + + const focus = () => { + selectorRef.value?.focus(); + }; + const blur = () => { + selectorRef.value?.blur(); + }; + expose({ + focus, + blur, + scrollTo: arg => listRef.value?.scrollTo(arg), + }); + + const mergedSearchValue = computed(() => { + if (props.mode !== 'combobox') { + return props.searchValue; + } + + const val = props.displayValues[0]?.value; + + return typeof val === 'string' || typeof val === 'number' ? String(val) : ''; + }); + + // ============================== Open ============================== + const initOpen = props.open !== undefined ? props.open : props.defaultOpen; + const innerOpen = ref(initOpen); + const mergedOpen = ref(initOpen); + const setInnerOpen = (val: boolean) => { + innerOpen.value = props.open !== undefined ? props.open : val; + mergedOpen.value = innerOpen.value; + }; + watch( + () => props.open, + () => { + setInnerOpen(props.open); + }, + ); + + // Not trigger `open` in `combobox` when `notFoundContent` is empty + const emptyListContent = computed(() => !props.notFoundContent && props.emptyOptions); + + watchEffect(() => { + mergedOpen.value = innerOpen.value; + if ( + props.disabled || + (emptyListContent.value && mergedOpen.value && props.mode === 'combobox') + ) { + mergedOpen.value = false; + } + }); + + const triggerOpen = computed(() => (emptyListContent.value ? false : mergedOpen.value)); + + const onToggleOpen = (newOpen?: boolean) => { + const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen.value; + + if (innerOpen.value !== nextOpen && !props.disabled) { + setInnerOpen(nextOpen); + if (props.onDropdownVisibleChange) { + props.onDropdownVisibleChange(nextOpen); + } + } + }; + + const tokenWithEnter = computed(() => + (props.tokenSeparators || []).some(tokenSeparator => ['\n', '\r\n'].includes(tokenSeparator)), + ); + + const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { + let ret = true; + let newSearchText = searchText; + props.onActiveValueChange?.(null); + + // Check if match the `tokenSeparators` + const patchLabels: string[] = isCompositing + ? null + : getSeparatedContent(searchText, props.tokenSeparators); + + // Ignore combobox since it's not split-able + if (props.mode !== 'combobox' && patchLabels) { + newSearchText = ''; + + props.onSearchSplit?.(patchLabels); + + // Should close when paste finish + onToggleOpen(false); + + // Tell Selector that break next actions + ret = false; + } + + if (props.onSearch && mergedSearchValue.value !== newSearchText) { + props.onSearch(newSearchText, { + source: fromTyping ? 'typing' : 'effect', + }); + } + + return ret; + }; + + // Only triggered when menu is closed & mode is tags + // If menu is open, OptionList will take charge + // If mode isn't tags, press enter is not meaningful when you can't see any option + const onInternalSearchSubmit = (searchText: string) => { + // prevent empty tags from appearing when you click the Enter button + if (!searchText || !searchText.trim()) { + return; + } + props.onSearch?.(searchText, { source: 'submit' }); + }; + + // Close will clean up single mode search text + watch( + mergedOpen, + () => { + if (!mergedOpen.value && !multiple.value && props.mode !== 'combobox') { + onInternalSearch('', false, false); + } + }, + { immediate: true }, + ); + + // ============================ Disabled ============================ + // Close dropdown & remove focus state when disabled change + watch( + () => props.disabled, + () => { + if (innerOpen.value && !!props.disabled) { + setInnerOpen(false); + } + }, + { immediate: true }, + ); + + // ============================ Keyboard ============================ + /** + * We record input value here to check if can press to clean up by backspace + * - null: Key is not down, this is reset by key up + * - true: Search text is empty when first time backspace down + * - false: Search text is not empty when first time backspace down + */ + const [getClearLock, setClearLock] = useLock(); + + // KeyDown + const onInternalKeyDown: KeyboardEventHandler = (event, ...rest) => { + const clearLock = getClearLock(); + const { which } = event; + + if (which === KeyCode.ENTER) { + // Do not submit form when type in the input + if (props.mode !== 'combobox') { + event.preventDefault(); + } + + // We only manage open state here, close logic should handle by list component + if (!mergedOpen.value) { + onToggleOpen(true); + } + } + + setClearLock(!!mergedSearchValue.value); + + // Remove value by `backspace` + if ( + which === KeyCode.BACKSPACE && + !clearLock && + multiple.value && + !mergedSearchValue.value && + props.displayValues.length + ) { + const cloneDisplayValues = [...props.displayValues]; + let removedDisplayValue = null; + + for (let i = cloneDisplayValues.length - 1; i >= 0; i -= 1) { + const current = cloneDisplayValues[i]; + + if (!current.disabled) { + cloneDisplayValues.splice(i, 1); + removedDisplayValue = current; + break; + } + } + + if (removedDisplayValue) { + props.onDisplayValuesChange(cloneDisplayValues, { + type: 'remove', + values: [removedDisplayValue], + }); + } + } + + if (mergedOpen.value && listRef.value) { + listRef.value.onKeydown(event, ...rest); + } + + props.onKeydown?.(event, ...rest); + }; + + // KeyUp + const onInternalKeyUp: KeyboardEventHandler = (event: KeyboardEvent, ...rest) => { + if (mergedOpen.value && listRef.value) { + listRef.value.onKeyup(event, ...rest); + } + + if (props.onKeyup) { + props.onKeyup(event, ...rest); + } + }; + + // ============================ Selector ============================ + const onSelectorRemove = (val: DisplayValueType) => { + const newValues = props.displayValues.filter(i => i !== val); + + props.onDisplayValuesChange(newValues, { + type: 'remove', + values: [val], + }); + }; + + // ========================== Focus / Blur ========================== + /** Record real focus status */ + const focusRef = ref(false); + + const onContainerFocus: FocusEventHandler = (...args) => { + setMockFocused(true); + + if (!props.disabled) { + if (props.onFocus && !focusRef.value) { + props.onFocus(...args); + } + + // `showAction` should handle `focus` if set + if (props.showAction && props.showAction.includes('focus')) { + onToggleOpen(true); + } + } + + focusRef.value = true; + }; + + const onContainerBlur: FocusEventHandler = (...args) => { + setMockFocused(false, () => { + focusRef.value = false; + onToggleOpen(false); + }); + + if (props.disabled) { + return; + } + const searchVal = mergedSearchValue.value; + if (searchVal) { + // `tags` mode should move `searchValue` into values + if (props.mode === 'tags') { + props.onSearch(searchVal, { source: 'submit' }); + } else if (props.mode === 'multiple') { + // `multiple` mode only clean the search value but not trigger event + props.onSearch('', { + source: 'blur', + }); + } + } + + if (props.onBlur) { + props.onBlur(...args); + } + }; + provide('VCSelectContainerEvent', { + focus: onContainerFocus, + blur: onContainerBlur, + }); + + // Give focus back of Select + const activeTimeoutIds: any[] = []; + + onMounted(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + onBeforeUnmount(() => { + activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId)); + activeTimeoutIds.splice(0, activeTimeoutIds.length); + }); + + const onInternalMouseDown: MouseEventHandler = (event, ...restArgs) => { + const { target } = event; + const popupElement: HTMLDivElement = triggerRef.value?.getPopupElement(); + + // We should give focus back to selector if clicked item is not focusable + if (popupElement && popupElement.contains(target as HTMLElement)) { + const timeoutId: any = setTimeout(() => { + const index = activeTimeoutIds.indexOf(timeoutId); + if (index !== -1) { + activeTimeoutIds.splice(index, 1); + } + + cancelSetMockFocused(); + + if (!mobile.value && !popupElement.contains(document.activeElement)) { + selectorRef.value?.focus(); + } + }); + + activeTimeoutIds.push(timeoutId); + } + + props.onMousedown?.(event, ...restArgs); + }; + + // ============================= Dropdown ============================== + const containerWidth = ref(null); + const instance = getCurrentInstance(); + const onPopupMouseEnter = () => { + // We need force update here since popup dom is render async + instance.update(); + }; + onMounted(() => { + watch( + triggerOpen, + () => { + if (triggerOpen.value) { + const newWidth = Math.ceil(containerRef.value?.offsetWidth); + if (containerWidth.value !== newWidth && !Number.isNaN(newWidth)) { + containerWidth.value = newWidth; + } + } + }, + { immediate: true }, + ); + }); + + // Close when click on non-select element + useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen); + useProvideBaseSelectProps( + toReactive({ + ...toRefs(props), + open: mergedOpen, + triggerOpen, + showSearch: mergedShowSearch, + multiple, + toggleOpen: onToggleOpen, + } as unknown as BaseSelectContextProps), + ); + return () => { + const { + prefixCls, + id, + + open, + defaultOpen, + + mode, + + // Search related + showSearch, + searchValue, + onSearch, + + // Icons + allowClear, + clearIcon, + showArrow, + inputIcon, + + // Others + disabled, + loading, + getInputElement, + getPopupContainer, + placement, + + // Dropdown + animation, + transitionName, + dropdownStyle, + dropdownClassName, + dropdownMatchSelectWidth, + dropdownRender, + dropdownAlign, + showAction, + direction, + + // Tags + tokenSeparators, + tagRender, + + // Events + onPopupScroll, + onDropdownVisibleChange, + onFocus, + onBlur, + onKeyup, + onKeydown, + onMousedown, + + onClear, + omitDomProps, + getRawInputElement, + displayValues, + onDisplayValuesChange, + emptyOptions, + activeDescendantId, + activeValue, + OptionList, + + ...restProps + } = { ...props, ...attrs } as BaseSelectProps; + // ============================= Input ============================== + // Only works in `combobox` + const customizeInputElement: any = + (mode === 'combobox' && getInputElement && getInputElement()) || null; + + // Used for customize replacement for `vc-cascader` + const customizeRawInputElement: any = + typeof getRawInputElement === 'function' && getRawInputElement(); + const domProps = { + ...restProps, + } as Omit; + + // Used for raw custom input trigger + let onTriggerVisibleChange: null | ((newOpen: boolean) => void); + if (customizeRawInputElement) { + onTriggerVisibleChange = (newOpen: boolean) => { + onToggleOpen(newOpen); + }; + } + + DEFAULT_OMIT_PROPS.forEach(propName => { + delete domProps[propName]; + }); + + omitDomProps?.forEach(propName => { + delete domProps[propName]; + }); + + // ============================= Arrow ============================== + const mergedShowArrow = + showArrow !== undefined ? showArrow : loading || (!multiple.value && mode !== 'combobox'); + let arrowNode: VNode | JSX.Element; + + if (mergedShowArrow) { + arrowNode = ( + + ); + } + + // ============================= Clear ============================== + let clearNode: VNode | JSX.Element; + const onClearMouseDown: MouseEventHandler = () => { + onClear?.(); + + onDisplayValuesChange([], { + type: 'clear', + values: displayValues, + }); + onInternalSearch('', false, false); + }; + + if (!disabled && allowClear && (displayValues.length || mergedSearchValue.value)) { + clearNode = ( + + × + + ); + } + + // =========================== OptionList =========================== + const optionList = ( + + ); + + // ============================= Select ============================= + const mergedClassName = classNames(prefixCls, attrs.class, { + [`${prefixCls}-focused`]: mockFocused.value, + [`${prefixCls}-multiple`]: multiple.value, + [`${prefixCls}-single`]: !multiple.value, + [`${prefixCls}-allow-clear`]: allowClear, + [`${prefixCls}-show-arrow`]: mergedShowArrow, + [`${prefixCls}-disabled`]: disabled, + [`${prefixCls}-loading`]: loading, + [`${prefixCls}-open`]: mergedOpen.value, + [`${prefixCls}-customize-input`]: customizeInputElement, + [`${prefixCls}-show-search`]: mergedShowSearch.value, + }); + + // >>> Selector + const selectorNode = ( + selectorDomRef.current} + onPopupVisibleChange={onTriggerVisibleChange} + onPopupMouseEnter={onPopupMouseEnter} + v-slots={{ + default: () => { + return customizeRawInputElement ? ( + isValidElement(customizeRawInputElement) && + cloneElement( + customizeRawInputElement, + { + ref: selectorDomRef, + }, + false, + true, + ) + ) : ( + + ); + }, + }} + > + ); + // >>> Render + let renderNode: VNode | JSX.Element; + + // Render raw + if (customizeRawInputElement) { + renderNode = selectorNode; + } else { + renderNode = ( +
+ {mockFocused.value && !mergedOpen.value && ( + + {/* Merge into one string to make screen reader work as expect */} + {`${displayValues + .map(({ label, value }) => + ['number', 'string'].includes(typeof label) ? label : value, + ) + .join(', ')}`} + + )} + {selectorNode} + + {arrowNode} + {clearNode} +
+ ); + } + return renderNode; + }; + }, +}); diff --git a/components/vc-select/OptGroup.tsx b/components/vc-select/OptGroup.tsx index 36c3ecc901..00a60e3534 100644 --- a/components/vc-select/OptGroup.tsx +++ b/components/vc-select/OptGroup.tsx @@ -1,8 +1,8 @@ import type { FunctionalComponent } from 'vue'; -import type { OptionGroupData } from './interface'; +import type { DefaultOptionType } from './Select'; -export type OptGroupProps = Omit; +export type OptGroupProps = Omit; export interface OptionGroupFC extends FunctionalComponent { /** Legacy for check if is a Option Group */ diff --git a/components/vc-select/Option.tsx b/components/vc-select/Option.tsx index a8d68184b7..6c98f3287c 100644 --- a/components/vc-select/Option.tsx +++ b/components/vc-select/Option.tsx @@ -1,8 +1,8 @@ import type { FunctionalComponent } from 'vue'; -import type { OptionCoreData } from './interface'; +import type { DefaultOptionType } from './Select'; -export interface OptionProps extends Omit { +export interface OptionProps extends Omit { /** Save for customize data */ [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any } diff --git a/components/vc-select/OptionList.tsx b/components/vc-select/OptionList.tsx index 0f6130e0e4..7cb1aa68d5 100644 --- a/components/vc-select/OptionList.tsx +++ b/components/vc-select/OptionList.tsx @@ -1,22 +1,12 @@ import TransBtn from './TransBtn'; -import PropTypes from '../_util/vue-types'; + import KeyCode from '../_util/KeyCode'; import classNames from '../_util/classNames'; import pickAttrs from '../_util/pickAttrs'; import { isValidElement } from '../_util/props-util'; import createRef from '../_util/createRef'; -import type { PropType } from 'vue'; import { computed, defineComponent, nextTick, reactive, watch } from 'vue'; import List from '../vc-virtual-list'; -import type { - OptionsType as SelectOptionsType, - OptionData, - RenderNode, - OnActiveValue, - FieldNames, -} from './interface'; -import type { RawValueType, FlattenOptionsType } from './interface/generator'; -import { fillFieldNames } from './utils/valueUtil'; import useMemo from '../_util/hooks/useMemo'; import { isPlatformMac } from './utils/platformUtil'; @@ -28,78 +18,28 @@ export interface RefOptionListProps { import type { EventHandler } from '../_util/EventInterface'; import omit from '../_util/omit'; -export interface OptionListProps { - prefixCls: string; - id: string; - options: OptionType[]; - fieldNames?: FieldNames; - flattenOptions: FlattenOptionsType; - height: number; - itemHeight: number; - values: Set; - multiple: boolean; - open: boolean; - defaultActiveFirstOption?: boolean; - notFoundContent?: any; - menuItemSelectedIcon?: RenderNode; - childrenAsData: boolean; - searchValue: string; - virtual: boolean; - direction?: 'ltr' | 'rtl'; - - onSelect: (value: RawValueType, option: { selected: boolean }) => void; - onToggleOpen: (open?: boolean) => void; - /** Tell Select that some value is now active to make accessibility work */ - onActiveValue: OnActiveValue; - onScroll: EventHandler; - - /** Tell Select that mouse enter the popup to force re-render */ - onMouseenter?: EventHandler; -} - -const OptionListProps = { - prefixCls: PropTypes.string, - id: PropTypes.string, - options: PropTypes.array, - fieldNames: PropTypes.object, - flattenOptions: PropTypes.array, - height: PropTypes.number, - itemHeight: PropTypes.number, - values: PropTypes.any, - multiple: PropTypes.looseBool, - open: PropTypes.looseBool, - defaultActiveFirstOption: PropTypes.looseBool, - notFoundContent: PropTypes.any, - menuItemSelectedIcon: PropTypes.any, - childrenAsData: PropTypes.looseBool, - searchValue: PropTypes.string, - virtual: PropTypes.looseBool, - direction: PropTypes.string, - - onSelect: PropTypes.func, - onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, - /** Tell Select that some value is now active to make accessibility work */ - onActiveValue: PropTypes.func, - onScroll: PropTypes.func, - - /** Tell Select that mouse enter the popup to force re-render */ - onMouseenter: PropTypes.func, -}; +import useBaseProps from './hooks/useBaseProps'; +import type { RawValueType } from './Select'; +import useSelectProps from './SelectContext'; +// export interface OptionListProps { +export type OptionListProps = Record; /** * Using virtual list of option display. * Will fallback to dom if use customize render. */ -const OptionList = defineComponent, { state?: any }>({ +const OptionList = defineComponent({ name: 'OptionList', inheritAttrs: false, slots: ['option'], - setup(props) { - const itemPrefixCls = computed(() => `${props.prefixCls}-item`); + setup(_, { expose, slots }) { + const baseProps = useBaseProps(); + const props = useSelectProps(); + const itemPrefixCls = computed(() => `${baseProps.prefixCls}-item`); const memoFlattenOptions = useMemo( () => props.flattenOptions, - [() => props.open, () => props.flattenOptions], + [() => baseProps.open, () => props.flattenOptions], next => next[0], ); @@ -124,7 +64,7 @@ const OptionList = defineComponent, { const current = (index + i * offset + len) % len; const { group, data } = memoFlattenOptions.value[current]; - if (!group && !(data as OptionData).disabled) { + if (!group && !data.disabled) { return current; } } @@ -152,7 +92,7 @@ const OptionList = defineComponent, { // Auto active first item when list length or searchValue changed watch( - [() => memoFlattenOptions.value.length, () => props.searchValue], + [() => memoFlattenOptions.value.length, () => baseProps.searchValue], () => { setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1); }, @@ -161,10 +101,10 @@ const OptionList = defineComponent, { // Auto scroll to item position in single mode watch( - [() => props.open, () => props.searchValue], + [() => baseProps.open, () => baseProps.searchValue], () => { - if (!props.multiple && props.open && props.values.size === 1) { - const value = Array.from(props.values)[0]; + if (!baseProps.multiple && baseProps.open && props.rawValues.size === 1) { + const value = Array.from(props.rawValues)[0]; const index = memoFlattenOptions.value.findIndex(({ data }) => data.value === value); if (index !== -1) { setActive(index); @@ -174,7 +114,7 @@ const OptionList = defineComponent, { } } // Force trigger scrollbar visible when open - if (props.open) { + if (baseProps.open) { nextTick(() => { listRef.current?.scrollTo(undefined); }); @@ -186,262 +126,253 @@ const OptionList = defineComponent, { // ========================== Values ========================== const onSelectValue = (value?: RawValueType) => { if (value !== undefined) { - props.onSelect(value, { selected: !props.values.has(value) }); + props.onSelect(value, { selected: !props.rawValues.has(value) }); } // Single mode should always close by select - if (!props.multiple) { - props.onToggleOpen(false); + if (!baseProps.multiple) { + baseProps.toggleOpen(false); } }; - + const getLabel = (item: Record) => item.label; function renderItem(index: number) { const item = memoFlattenOptions.value[index]; if (!item) return null; - const itemData = (item.data || {}) as OptionData; - const { value, label, children } = itemData; + const itemData = item.data || {}; + const { value } = itemData; + const { group } = item; const attrs = pickAttrs(itemData, true); - const mergedLabel = props.childrenAsData ? children : label; + const mergedLabel = getLabel(item); return item ? (
{value}
) : null; } - return { - memoFlattenOptions, - renderItem, - listRef, - state, - onListMouseDown, - itemPrefixCls, - setActive, - onSelectValue, - onKeydown: (event: KeyboardEvent) => { - const { which, ctrlKey } = event; - switch (which) { - // >>> Arrow keys & ctrl + n/p on Mac - case KeyCode.N: - case KeyCode.P: - case KeyCode.UP: - case KeyCode.DOWN: { - let offset = 0; - if (which === KeyCode.UP) { - offset = -1; - } else if (which === KeyCode.DOWN) { + const onKeydown = (event: KeyboardEvent) => { + const { which, ctrlKey } = event; + switch (which) { + // >>> Arrow keys & ctrl + n/p on Mac + case KeyCode.N: + case KeyCode.P: + case KeyCode.UP: + case KeyCode.DOWN: { + let offset = 0; + if (which === KeyCode.UP) { + offset = -1; + } else if (which === KeyCode.DOWN) { + offset = 1; + } else if (isPlatformMac() && ctrlKey) { + if (which === KeyCode.N) { offset = 1; - } else if (isPlatformMac() && ctrlKey) { - if (which === KeyCode.N) { - offset = 1; - } else if (which === KeyCode.P) { - offset = -1; - } - } - - if (offset !== 0) { - const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); - scrollIntoView(nextActiveIndex); - setActive(nextActiveIndex, true); + } else if (which === KeyCode.P) { + offset = -1; } + } - break; + if (offset !== 0) { + const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); + scrollIntoView(nextActiveIndex); + setActive(nextActiveIndex, true); } - // >>> Select - case KeyCode.ENTER: { - // value - const item = memoFlattenOptions.value[state.activeIndex]; - if (item && !item.data.disabled) { - onSelectValue(item.data.value); - } else { - onSelectValue(undefined); - } + break; + } - if (props.open) { - event.preventDefault(); - } + // >>> Select + case KeyCode.ENTER: { + // value + const item = memoFlattenOptions.value[state.activeIndex]; + if (item && !item.data.disabled) { + onSelectValue(item.data.value); + } else { + onSelectValue(undefined); + } - break; + if (baseProps.open) { + event.preventDefault(); } - // >>> Close - case KeyCode.ESC: { - props.onToggleOpen(false); - if (props.open) { - event.stopPropagation(); - } + break; + } + + // >>> Close + case KeyCode.ESC: { + baseProps.toggleOpen(false); + if (baseProps.open) { + event.stopPropagation(); } } - }, - onKeyup: () => {}, + } + }; + const onKeyup = () => {}; - scrollTo: (index: number) => { - scrollIntoView(index); - }, + const scrollTo = (index: number) => { + scrollIntoView(index); }; - }, - render() { - const { - renderItem, - listRef, - onListMouseDown, - itemPrefixCls, - setActive, - onSelectValue, - memoFlattenOptions, - $slots, - } = this as any; - const { - id, - childrenAsData, - values, - height, - itemHeight, - menuItemSelectedIcon, - notFoundContent, - virtual, - fieldNames, - onScroll, - onMouseenter, - } = this.$props; - const renderOption = $slots.option; - const { activeIndex } = this.state; - const omitFieldNameList = Object.values(fillFieldNames(fieldNames)); - // ========================== Render ========================== - if (memoFlattenOptions.length === 0) { + expose({ + onKeydown, + onKeyup, + scrollTo, + }); + return () => { + // const { + // renderItem, + // listRef, + // onListMouseDown, + // itemPrefixCls, + // setActive, + // onSelectValue, + // memoFlattenOptions, + // $slots, + // } = this as any; + const { id, notFoundContent, onPopupScroll } = baseProps; + const { menuItemSelectedIcon, rawValues, fieldNames, virtual, listHeight, listItemHeight } = + props; + + const renderOption = slots.option; + const { activeIndex } = state; + const omitFieldNameList = Object.keys(fieldNames).map(key => fieldNames[key]); + // ========================== Render ========================== + if (memoFlattenOptions.value.length === 0) { + return ( +
+ {notFoundContent} +
+ ); + } return ( -
- {notFoundContent} -
- ); - } - return ( - <> -
- {renderItem(activeIndex - 1)} - {renderItem(activeIndex)} - {renderItem(activeIndex + 1)} -
- { - const { key } = data; - // Group - if (group) { + <> +
+ {renderItem(activeIndex - 1)} + {renderItem(activeIndex)} + {renderItem(activeIndex + 1)} +
+ { + const { group, groupOption, data, label, value } = item; + const { key } = data; + // Group + if (group) { + return ( +
+ {renderOption ? renderOption(data) : label !== undefined ? label : key} +
+ ); + } + + const { + disabled, + title, + children, + style, + class: cls, + className, + ...otherProps + } = data; + const passedProps = omit(otherProps, omitFieldNameList); + // Option + const selected = rawValues.has(value); + + const optionPrefixCls = `${itemPrefixCls.value}-option`; + const optionClassName = classNames( + itemPrefixCls.value, + optionPrefixCls, + cls, + className, + { + [`${optionPrefixCls}-grouped`]: groupOption, + [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled, + [`${optionPrefixCls}-disabled`]: disabled, + [`${optionPrefixCls}-selected`]: selected, + }, + ); + + const mergedLabel = getLabel(item); + + const iconVisible = + !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected; + + const content = mergedLabel || value; + // https://github.com/ant-design/ant-design/issues/26717 + let optionTitle = + typeof content === 'string' || typeof content === 'number' + ? content.toString() + : undefined; + if (title !== undefined) { + optionTitle = title; + } + return ( -
- {renderOption ? renderOption(data) : label !== undefined ? label : key} +
{ + if (otherProps.onMousemove) { + otherProps.onMousemove(e); + } + if (activeIndex === itemIndex || disabled) { + return; + } + setActive(itemIndex); + }} + onClick={e => { + if (!disabled) { + onSelectValue(value); + } + if (otherProps.onClick) { + otherProps.onClick(e); + } + }} + style={style} + > +
+ {renderOption ? renderOption(data) : content} +
+ {isValidElement(menuItemSelectedIcon) || selected} + {iconVisible && ( + + {selected ? '✓' : null} + + )}
); - } - - const { - disabled, - title, - children, - style, - class: cls, - className, - ...otherProps - } = data; - const passedProps = omit(otherProps, omitFieldNameList); - // Option - const selected = values.has(value); - - const optionPrefixCls = `${itemPrefixCls}-option`; - const optionClassName = classNames(itemPrefixCls, optionPrefixCls, cls, className, { - [`${optionPrefixCls}-grouped`]: groupOption, - [`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled, - [`${optionPrefixCls}-disabled`]: disabled, - [`${optionPrefixCls}-selected`]: selected, - }); - - const mergedLabel = childrenAsData ? children : label; - - const iconVisible = - !menuItemSelectedIcon || typeof menuItemSelectedIcon === 'function' || selected; - - const content = mergedLabel || value; - // https://github.com/ant-design/ant-design/issues/26717 - let optionTitle = - typeof content === 'string' || typeof content === 'number' - ? content.toString() - : undefined; - if (title !== undefined) { - optionTitle = title; - } - - return ( -
{ - if (otherProps.onMousemove) { - otherProps.onMousemove(e); - } - if (activeIndex === itemIndex || disabled) { - return; - } - setActive(itemIndex); - }} - onClick={e => { - if (!disabled) { - onSelectValue(value); - } - if (otherProps.onClick) { - otherProps.onClick(e); - } - }} - style={style} - > -
- {renderOption ? renderOption(data) : content} -
- {isValidElement(menuItemSelectedIcon) || selected} - {iconVisible && ( - - {selected ? '✓' : null} - - )} -
- ); - }, - }} - > - - ); + }, + }} + > + + ); + }; }, }); -OptionList.props = OptionListProps; - export default OptionList; diff --git a/components/vc-select/Select.tsx b/components/vc-select/Select.tsx index ac1051831b..796668dcc7 100644 --- a/components/vc-select/Select.tsx +++ b/components/vc-select/Select.tsx @@ -1,6 +1,6 @@ /** * To match accessibility requirement, we always provide an input in the component. - * Other element will not set `tabIndex` to avoid `onBlur` sequence problem. + * Other element will not set `tabindex` to avoid `onBlur` sequence problem. * For focused select, we set `aria-live="polite"` to update the accessibility content. * * ref: @@ -29,76 +29,614 @@ * - `combobox` mode not support `optionLabelProp` */ -import type { OptionsType as SelectOptionsType } from './interface'; -import SelectOptionList from './OptionList'; -import Option from './Option'; -import OptGroup from './OptGroup'; -import { convertChildrenToData as convertSelectChildrenToData } from './utils/legacyUtil'; -import { - getLabeledValue as getSelectLabeledValue, - filterOptions as selectDefaultFilterOptions, - isValueDisabled as isSelectValueDisabled, - findValueOption as findSelectValueOption, - flattenOptions, - fillOptionsWithMissingValue, -} from './utils/valueUtil'; -import type { SelectProps } from './generate'; -import generateSelector, { selectBaseProps } from './generate'; -import type { DefaultValueType } from './interface/generator'; +import BaseSelect, { baseSelectPropsWithoutPrivate, isMultiple } from './BaseSelect'; +import type { DisplayValueType, BaseSelectRef, BaseSelectProps } from './BaseSelect'; +import OptionList from './OptionList'; +import useOptions from './hooks/useOptions'; +import type { SelectContextProps } from './SelectContext'; +import { useProvideSelectProps } from './SelectContext'; +import useId from './hooks/useId'; +import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil'; import warningProps from './utils/warningPropsUtil'; -import { defineComponent, ref } from 'vue'; +import { toArray } from './utils/commonUtil'; +import useFilterOptions from './hooks/useFilterOptions'; +import useCache from './hooks/useCache'; +import type { Key, VueNode } from '../_util/type'; +import { computed, defineComponent, ref, toRef, watchEffect } from 'vue'; +import type { ExtractPropTypes, PropType } from 'vue'; +import PropTypes from '../_util/vue-types'; +import { initDefaultProps } from '../_util/props-util'; +import useMergedState from '../_util/hooks/useMergedState'; +import useState from '../_util/hooks/useState'; +import { toReactive } from '../_util/toReactive'; +import omit from '../_util/omit'; -const RefSelect = generateSelector({ - prefixCls: 'rc-select', - components: { - optionList: SelectOptionList as any, - }, - convertChildrenToData: convertSelectChildrenToData, - flattenOptions, - getLabeledValue: getSelectLabeledValue, - filterOptions: selectDefaultFilterOptions, - isValueDisabled: isSelectValueDisabled, - findValueOption: findSelectValueOption, - warningProps, - fillOptionsWithMissingValue, -}); +const OMIT_DOM_PROPS = ['inputValue']; + +export type OnActiveValue = ( + active: RawValueType, + index: number, + info?: { source?: 'keyboard' | 'mouse' }, +) => void; + +export type OnInternalSelect = (value: RawValueType, info: { selected: boolean }) => void; + +export type RawValueType = string | number; +export interface LabelInValueType { + label: any; + value: RawValueType; + /** @deprecated `key` is useless since it should always same as `value` */ + key?: Key; +} + +export type DraftValueType = + | RawValueType + | LabelInValueType + | DisplayValueType + | (RawValueType | LabelInValueType | DisplayValueType)[]; -export type ExportedSelectProps = SelectProps< - SelectOptionsType[number], - T ->; +export type FilterFunc = (inputValue: string, option?: OptionType) => boolean; -export function selectProps() { - return selectBaseProps(); +export interface FieldNames { + value?: string; + label?: string; + options?: string; } -const Select = defineComponent({ +export interface BaseOptionType { + disabled?: boolean; + [name: string]: any; +} + +export interface DefaultOptionType extends BaseOptionType { + label?: any; + value?: string | number | null; + children?: Omit[]; +} + +export type SelectHandler = + | ((value: RawValueType | LabelInValueType, option: OptionType) => void) + | ((value: ValueType, option: OptionType) => void); + +export function selectProps< + ValueType = any, + OptionType extends BaseOptionType = DefaultOptionType, +>() { + return { + ...baseSelectPropsWithoutPrivate(), + prefixCls: String, + id: String, + + backfill: { type: Boolean, default: undefined }, + + // >>> Field Names + fieldNames: Object as PropType, + + // >>> Search + /** @deprecated Use `searchValue` instead */ + inputValue: String, + searchValue: String, + onSearch: Function as PropType<(value: string) => void>, + autoClearSearchValue: { type: Boolean, default: undefined }, + + // >>> Select + onSelect: Function as PropType>, + onDeselect: Function as PropType>, + + // >>> Options + /** + * In Select, `false` means do nothing. + * In TreeSelect, `false` will highlight match item. + * It's by design. + */ + filterOption: { + type: [Boolean, Function] as PropType>, + default: undefined, + }, + filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>, + optionFilterProp: String, + optionLabelProp: String, + options: Array as PropType, + defaultActiveFirstOption: { type: Boolean, default: undefined }, + virtual: { type: Boolean, default: undefined }, + listHeight: Number, + listItemHeight: Number, + + // >>> Icon + menuItemSelectedIcon: PropTypes.any, + + mode: String as PropType<'combobox' | 'multiple' | 'tags'>, + labelInValue: { type: Boolean, default: undefined }, + value: PropTypes.any, + defaultValue: PropTypes.any, + onChange: Function as PropType<(value: ValueType, option: OptionType | OptionType[]) => void>, + children: Array as PropType, + }; +} + +export type SelectProps = Partial>>; + +function isRawValue(value: DraftValueType): value is RawValueType { + return !value || typeof value !== 'object'; +} + +export default defineComponent({ name: 'Select', inheritAttrs: false, - Option, - OptGroup, - props: RefSelect.props, - setup(props, { attrs, expose, slots }) { - const selectRef = ref(); + props: initDefaultProps(selectProps(), { + prefixCls: 'vc-select', + autoClearSearchValue: true, + listHeight: 200, + listItemHeight: 20, + }), + setup(props, { expose, attrs, slots }) { + const mergedId = useId(toRef(props, 'id')); + const multiple = computed(() => isMultiple(props.mode)); + const childrenAsData = computed(() => !!(!props.options && props.children)); + + const mergedFilterOption = computed(() => { + if (props.filterOption === undefined && props.mode === 'combobox') { + return false; + } + return props.filterOption; + }); + + // ========================= FieldNames ========================= + const mergedFieldNames = computed(() => fillFieldNames(props.fieldNames, childrenAsData.value)); + + // =========================== Search =========================== + const [mergedSearchValue, setSearchValue] = useMergedState('', { + value: computed(() => + props.searchValue !== undefined ? props.searchValue : props.inputValue, + ), + postState: search => search || '', + }); + + // =========================== Option =========================== + const parsedOptions = useOptions( + toRef(props, 'options'), + toRef(props, 'children'), + mergedFieldNames, + ); + const { valueOptions, labelOptions, options: mergedOptions } = parsedOptions; + + // ========================= Wrap Value ========================= + const convert2LabelValues = (draftValues: DraftValueType) => { + // Convert to array + const valueList = toArray(draftValues); + + // Convert to labelInValue type + return valueList.map(val => { + let rawValue: RawValueType; + let rawLabel: any; + let rawKey: Key; + let rawDisabled: boolean | undefined; + + // Fill label & value + if (isRawValue(val)) { + rawValue = val; + } else { + rawKey = val.key; + rawLabel = val.label; + rawValue = val.value ?? rawKey; + } + + const option = valueOptions.value.get(rawValue); + if (option) { + // Fill missing props + if (rawLabel === undefined) + rawLabel = option?.[props.optionLabelProp || mergedFieldNames.value.label]; + if (rawKey === undefined) rawKey = option?.key ?? rawValue; + rawDisabled = option?.disabled; + + // Warning if label not same as provided + // if (process.env.NODE_ENV !== 'production' && !isRawValue(val)) { + // const optionLabel = option?.[mergedFieldNames.value.label]; + // if (optionLabel !== undefined && optionLabel !== rawLabel) { + // warning(false, '`label` of `value` is not same as `label` in Select options.'); + // } + // } + } + + return { + label: rawLabel, + value: rawValue, + key: rawKey, + disabled: rawDisabled, + option, + }; + }); + }; + + // =========================== Values =========================== + const [internalValue, setInternalValue] = useMergedState(props.defaultValue, { + value: toRef(props, 'value'), + }); + + // Merged value with LabelValueType + const rawLabeledValues = computed(() => { + const values = convert2LabelValues(internalValue.value); + + // combobox no need save value when it's empty + if (props.mode === 'combobox' && !values[0]?.value) { + return []; + } + + return values; + }); + + // Fill label with cache to avoid option remove + const [mergedValues, getMixedOption] = useCache(rawLabeledValues, valueOptions); + + const displayValues = computed(() => { + // `null` need show as placeholder instead + // https://github.com/ant-design/ant-design/issues/25057 + if (!props.mode && mergedValues.value.length === 1) { + const firstValue = mergedValues.value[0]; + if ( + firstValue.value === null && + (firstValue.label === null || firstValue.label === undefined) + ) { + return []; + } + } + + return mergedValues.value.map(item => ({ + ...item, + label: item.label ?? item.value, + })); + }); + + /** Convert `displayValues` to raw value type set */ + const rawValues = computed(() => new Set(mergedValues.value.map(val => val.value))); + + watchEffect( + () => { + if (props.mode === 'combobox') { + const strValue = mergedValues.value[0]?.value; + + if (strValue !== undefined && strValue !== null) { + setSearchValue(String(strValue)); + } + } + }, + { flush: 'post' }, + ); + + // ======================= Display Option ======================= + // Create a placeholder item if not exist in `options` + const createTagOption = (val: RawValueType, label?: any) => { + const mergedLabel = label ?? val; + return { + [mergedFieldNames.value.value]: val, + [mergedFieldNames.value.label]: mergedLabel, + } as DefaultOptionType; + }; + + // Fill tag as option if mode is `tags` + const filledTagOptions = computed(() => { + if (props.mode !== 'tags') { + return mergedOptions.value; + } + + // >>> Tag mode + const cloneOptions = [...mergedOptions.value]; + + // Check if value exist in options (include new patch item) + const existOptions = (val: RawValueType) => valueOptions.value.has(val); + + // Fill current value as option + [...mergedValues.value] + .sort((a, b) => (a.value < b.value ? -1 : 1)) + .forEach(item => { + const val = item.value; + + if (!existOptions(val)) { + cloneOptions.push(createTagOption(val, item.label)); + } + }); + + return cloneOptions; + }); + + const filteredOptions = useFilterOptions( + filledTagOptions, + mergedFieldNames, + mergedSearchValue, + mergedFilterOption, + toRef(props, 'optionFilterProp'), + ); + + // Fill options with search value if needed + const filledSearchOptions = computed(() => { + if ( + props.mode !== 'tags' || + !mergedSearchValue.value || + filteredOptions.value.some( + item => item[props.optionFilterProp || 'value'] === mergedSearchValue.value, + ) + ) { + return filteredOptions.value; + } + + // Fill search value as option + return [createTagOption(mergedSearchValue.value), ...filteredOptions.value]; + }); + + const orderedFilteredOptions = computed(() => { + if (!props.filterSort) { + return filledSearchOptions.value; + } + + return [...filledSearchOptions.value].sort((a, b) => props.filterSort(a, b)); + }); + + const displayOptions = computed(() => + flattenOptions(orderedFilteredOptions.value, { + fieldNames: mergedFieldNames.value, + childrenAsData: childrenAsData.value, + }), + ); + + // =========================== Change =========================== + const triggerChange = (values: DraftValueType) => { + const labeledValues = convert2LabelValues(values); + setInternalValue(labeledValues); + + if ( + props.onChange && + // Trigger event only when value changed + (labeledValues.length !== mergedValues.value.length || + labeledValues.some((newVal, index) => mergedValues.value[index]?.value !== newVal?.value)) + ) { + const returnValues = props.labelInValue ? labeledValues : labeledValues.map(v => v.value); + const returnOptions = labeledValues.map(v => + injectPropsWithOption(getMixedOption(v.value)), + ); + + props.onChange( + // Value + multiple.value ? returnValues : returnValues[0], + // Option + multiple.value ? returnOptions : returnOptions[0], + ); + } + }; + + // ======================= Accessibility ======================== + const [activeValue, setActiveValue] = useState(null); + const [accessibilityIndex, setAccessibilityIndex] = useState(0); + const mergedDefaultActiveFirstOption = computed(() => + props.defaultActiveFirstOption !== undefined + ? props.defaultActiveFirstOption + : props.mode !== 'combobox', + ); + + const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => { + setAccessibilityIndex(index); + + if (props.backfill && props.mode === 'combobox' && active !== null && source === 'keyboard') { + setActiveValue(String(active)); + } + }; + + // ========================= OptionList ========================= + const triggerSelect = (val: RawValueType, selected: boolean) => { + const getSelectEnt = (): [RawValueType | LabelInValueType, DefaultOptionType] => { + const option = getMixedOption(val); + return [ + props.labelInValue + ? { + label: option?.[mergedFieldNames.value.label], + value: val, + key: option.key ?? val, + } + : val, + injectPropsWithOption(option), + ]; + }; + + if (selected && props.onSelect) { + const [wrappedValue, option] = getSelectEnt(); + props.onSelect(wrappedValue, option); + } else if (!selected && props.onDeselect) { + const [wrappedValue, option] = getSelectEnt(); + props.onDeselect(wrappedValue, option); + } + }; + + // Used for OptionList selection + const onInternalSelect = (val, info) => { + let cloneValues: (RawValueType | DisplayValueType)[]; + + // Single mode always trigger select only with option list + const mergedSelect = multiple.value ? info.selected : true; + + if (mergedSelect) { + cloneValues = multiple.value ? [...mergedValues.value, val] : [val]; + } else { + cloneValues = mergedValues.value.filter(v => v.value !== val); + } + + triggerChange(cloneValues); + triggerSelect(val, mergedSelect); + + // Clean search value if single or configured + if (props.mode === 'combobox') { + // setSearchValue(String(val)); + setActiveValue(''); + } else if (!multiple.value || props.autoClearSearchValue) { + setSearchValue(''); + setActiveValue(''); + } + }; + + // ======================= Display Change ======================= + // BaseSelect display values change + const onDisplayValuesChange: BaseSelectProps['onDisplayValuesChange'] = (nextValues, info) => { + triggerChange(nextValues); + + if (info.type === 'remove' || info.type === 'clear') { + info.values.forEach(item => { + triggerSelect(item.value, false); + }); + } + }; + + // =========================== Search =========================== + const onInternalSearch: BaseSelectProps['onSearch'] = (searchText, info) => { + setSearchValue(searchText); + setActiveValue(null); + + // [Submit] Tag mode should flush input + if (info.source === 'submit') { + const formatted = (searchText || '').trim(); + // prevent empty tags from appearing when you click the Enter button + if (formatted) { + const newRawValues = Array.from(new Set([...rawValues.value, formatted])); + triggerChange(newRawValues); + triggerSelect(formatted, true); + setSearchValue(''); + } + + return; + } + + if (info.source !== 'blur') { + if (props.mode === 'combobox') { + triggerChange(searchText); + } + + props.onSearch?.(searchText); + } + }; + + const onInternalSearchSplit: BaseSelectProps['onSearchSplit'] = words => { + let patchValues: RawValueType[] = words; + + if (props.mode !== 'tags') { + patchValues = words + .map(word => { + const opt = labelOptions.value.get(word); + return opt?.value; + }) + .filter(val => val !== undefined); + } + + const newRawValues = Array.from(new Set([...rawValues.value, ...patchValues])); + triggerChange(newRawValues); + newRawValues.forEach(newRawValue => { + triggerSelect(newRawValue, true); + }); + }; + const realVirtual = computed( + () => props.virtual !== false && props.dropdownMatchSelectWidth !== false, + ); + useProvideSelectProps( + toReactive({ + ...parsedOptions, + flattenOptions: displayOptions, + onActiveValue, + defaultActiveFirstOption: mergedDefaultActiveFirstOption, + onSelect: onInternalSelect, + menuItemSelectedIcon: toRef(props, 'menuItemSelectedIcon'), + rawValues, + fieldNames: mergedFieldNames, + virtual: realVirtual, + listHeight: toRef(props, 'listHeight'), + listItemHeight: toRef(props, 'listItemHeight'), + childrenAsData, + } as unknown as SelectContextProps), + ); + + // ========================== Warning =========================== + if (process.env.NODE_ENV !== 'production') { + watchEffect( + () => { + warningProps(props); + }, + { flush: 'post' }, + ); + } + const selectRef = ref(); expose({ - focus: () => { + focus() { selectRef.value?.focus(); }, - blur: () => { + blur() { selectRef.value?.blur(); }, + scrollTo(arg) { + selectRef.value?.scrollTo(arg); + }, + } as BaseSelectRef); + const pickProps = computed(() => { + return omit(props, [ + 'id', + 'mode', + 'prefixCls', + 'backfill', + 'fieldNames', + + // Search + 'inputValue', + 'searchValue', + 'onSearch', + 'autoClearSearchValue', + + // Select + 'onSelect', + 'onDeselect', + 'dropdownMatchSelectWidth', + + // Options + 'filterOption', + 'filterSort', + 'optionFilterProp', + 'optionLabelProp', + 'options', + 'children', + 'defaultActiveFirstOption', + 'menuItemSelectedIcon', + 'virtual', + 'listHeight', + 'listItemHeight', + + // Value + 'value', + 'defaultValue', + 'labelInValue', + 'onChange', + ]); }); return () => { return ( - >> MISC + id={mergedId} + prefixCls={props.prefixCls} + ref={selectRef} + omitDomProps={OMIT_DOM_PROPS} + mode={props.mode} + // >>> Values + displayValues={displayValues.value} + onDisplayValuesChange={onDisplayValuesChange} + // >>> Search + searchValue={mergedSearchValue.value} + onSearch={onInternalSearch} + onSearchSplit={onInternalSearchSplit} + dropdownMatchSelectWidth={props.dropdownMatchSelectWidth} + // >>> OptionList + OptionList={OptionList} + emptyOptions={!displayOptions.value.length} + // >>> Accessibility + activeValue={activeValue.value} + activeDescendantId={`${mergedId}_list_${accessibilityIndex.value}`} v-slots={slots} - children={slots.default?.() || []} /> ); }; }, }); -export default Select; diff --git a/components/vc-select/SelectContext.ts b/components/vc-select/SelectContext.ts new file mode 100644 index 0000000000..88d2760ce4 --- /dev/null +++ b/components/vc-select/SelectContext.ts @@ -0,0 +1,36 @@ +/** + * BaseSelect provide some parsed data into context. + * You can use this hooks to get them. + */ + +import type { InjectionKey } from 'vue'; +import { inject, provide } from 'vue'; +import type { RawValueType, RenderNode } from './BaseSelect'; +import type { FlattenOptionData } from './interface'; +import type { BaseOptionType, FieldNames, OnActiveValue, OnInternalSelect } from './Select'; + +// Use any here since we do not get the type during compilation +export interface SelectContextProps { + options: BaseOptionType[]; + flattenOptions: FlattenOptionData[]; + onActiveValue: OnActiveValue; + defaultActiveFirstOption?: boolean; + onSelect: OnInternalSelect; + menuItemSelectedIcon?: RenderNode; + rawValues: Set; + fieldNames?: FieldNames; + virtual?: boolean; + listHeight?: number; + listItemHeight?: number; + childrenAsData?: boolean; +} + +const SelectContextKey: InjectionKey = Symbol('SelectContextKey'); + +export function useProvideSelectProps(props: SelectContextProps) { + return provide(SelectContextKey, props); +} + +export default function useSelectProps() { + return inject(SelectContextKey, {} as SelectContextProps); +} diff --git a/components/vc-select/SelectTrigger.tsx b/components/vc-select/SelectTrigger.tsx index cd6dd66766..a220d11d99 100644 --- a/components/vc-select/SelectTrigger.tsx +++ b/components/vc-select/SelectTrigger.tsx @@ -1,19 +1,12 @@ import Trigger from '../vc-trigger'; import PropTypes from '../_util/vue-types'; -import { getSlot } from '../_util/props-util'; import classNames from '../_util/classNames'; -import createRef from '../_util/createRef'; import type { CSSProperties } from 'vue'; -import { defineComponent } from 'vue'; -import type { RenderDOMFunc } from './interface'; -import type { DropdownRender } from './interface/generator'; -import type { Placement } from './generate'; +import { computed, ref, defineComponent } from 'vue'; import type { VueNode } from '../_util/type'; +import type { DropdownRender, Placement, RenderDOMFunc } from './BaseSelect'; -const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { - // Enable horizontal overflow auto-adjustment when a custom dropdown width is provided - const adjustX = typeof dropdownMatchSelectWidth !== 'number' ? 0 : 1; - +const getBuiltInPlacements = (adjustX: number) => { return { bottomLeft: { points: ['tl', 'bl'], @@ -49,6 +42,19 @@ const getBuiltInPlacements = (dropdownMatchSelectWidth: number | boolean) => { }, }; }; + +const getAdjustX = ( + adjustXDependencies: Pick, +) => { + const { autoAdjustOverflow, dropdownMatchSelectWidth } = adjustXDependencies; + if (!!autoAdjustOverflow) return 1; + // Enable horizontal overflow auto-adjustment when a custom dropdown width is provided + return typeof dropdownMatchSelectWidth !== 'number' ? 0 : 1; +}; +export interface RefTriggerProps { + getPopupElement: () => HTMLDivElement; +} + export interface SelectTriggerProps { prefixCls: string; disabled: boolean; @@ -66,103 +72,122 @@ export interface SelectTriggerProps { getPopupContainer?: RenderDOMFunc; dropdownAlign: object; empty: boolean; + autoAdjustOverflow?: boolean; getTriggerDOMNode: () => any; + onPopupVisibleChange?: (visible: boolean) => void; + + onPopupMouseEnter: () => void; } const SelectTrigger = defineComponent({ name: 'SelectTrigger', inheritAttrs: false, - created() { - this.popupRef = createRef(); - }, - - methods: { - getPopupElement() { - return this.popupRef.current; - }, - }, - - render() { - const { empty = false, ...props } = { ...this.$props, ...this.$attrs }; - const { - visible, - dropdownAlign, - prefixCls, - popupElement, - dropdownClassName, - dropdownStyle, - direction = 'ltr', - placement, - dropdownMatchSelectWidth, - containerWidth, - dropdownRender, - animation, - transitionName, - getPopupContainer, - getTriggerDOMNode, - } = props as SelectTriggerProps; - const dropdownPrefixCls = `${prefixCls}-dropdown`; - - let popupNode = popupElement; - if (dropdownRender) { - popupNode = dropdownRender({ menuNode: popupElement, props }); - } + props: { + dropdownAlign: PropTypes.object, + visible: PropTypes.looseBool, + disabled: PropTypes.looseBool, + dropdownClassName: PropTypes.string, + dropdownStyle: PropTypes.object, + placement: PropTypes.string, + empty: PropTypes.looseBool, + autoAdjustOverflow: PropTypes.looseBool, + prefixCls: PropTypes.string, + popupClassName: PropTypes.string, + animation: PropTypes.string, + transitionName: PropTypes.string, + getPopupContainer: PropTypes.func, + dropdownRender: PropTypes.func, + containerWidth: PropTypes.number, + dropdownMatchSelectWidth: PropTypes.oneOfType([Number, Boolean]).def(true), + popupElement: PropTypes.any, + direction: PropTypes.string, + getTriggerDOMNode: PropTypes.func, + onPopupVisibleChange: PropTypes.func, + onPopupMouseEnter: PropTypes.func, + } as any, + setup(props, { slots, attrs, expose }) { + const builtInPlacements = computed(() => { + const { autoAdjustOverflow, dropdownMatchSelectWidth } = props; + return getBuiltInPlacements( + getAdjustX({ + autoAdjustOverflow, + dropdownMatchSelectWidth, + }), + ); + }); + const popupRef = ref(); + expose({ + getPopupElement: () => { + return popupRef.value; + }, + }); + return () => { + const { empty = false, ...restProps } = { ...props, ...attrs }; + const { + visible, + dropdownAlign, + prefixCls, + popupElement, + dropdownClassName, + dropdownStyle, + direction = 'ltr', + placement, + dropdownMatchSelectWidth, + containerWidth, + dropdownRender, + animation, + transitionName, + getPopupContainer, + getTriggerDOMNode, + onPopupVisibleChange, + onPopupMouseEnter, + } = restProps as SelectTriggerProps; + const dropdownPrefixCls = `${prefixCls}-dropdown`; - const builtInPlacements = getBuiltInPlacements(dropdownMatchSelectWidth); + let popupNode = popupElement; + if (dropdownRender) { + popupNode = dropdownRender({ menuNode: popupElement, props }); + } - const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; + const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; - const popupStyle = { minWidth: `${containerWidth}px`, ...dropdownStyle }; + const popupStyle = { minWidth: `${containerWidth}px`, ...dropdownStyle }; - if (typeof dropdownMatchSelectWidth === 'number') { - popupStyle.width = `${dropdownMatchSelectWidth}px`; - } else if (dropdownMatchSelectWidth) { - popupStyle.width = `${containerWidth}px`; - } - return ( - {popupNode}
} - popupAlign={dropdownAlign} - popupVisible={visible} - getPopupContainer={getPopupContainer} - popupClassName={classNames(dropdownClassName, { - [`${dropdownPrefixCls}-empty`]: empty, - })} - popupStyle={popupStyle} - getTriggerDOMNode={getTriggerDOMNode} - > - {getSlot(this)[0]} - - ); + if (typeof dropdownMatchSelectWidth === 'number') { + popupStyle.width = `${dropdownMatchSelectWidth}px`; + } else if (dropdownMatchSelectWidth) { + popupStyle.width = `${containerWidth}px`; + } + return ( + ( +
+ {popupNode} +
+ ), + }} + >
+ ); + }; }, }); -SelectTrigger.props = { - dropdownAlign: PropTypes.object, - visible: PropTypes.looseBool, - disabled: PropTypes.looseBool, - dropdownClassName: PropTypes.string, - dropdownStyle: PropTypes.object, - placement: PropTypes.string, - empty: PropTypes.looseBool, - prefixCls: PropTypes.string, - popupClassName: PropTypes.string, - animation: PropTypes.string, - transitionName: PropTypes.string, - getPopupContainer: PropTypes.func, - dropdownRender: PropTypes.func, - containerWidth: PropTypes.number, - dropdownMatchSelectWidth: PropTypes.oneOfType([Number, Boolean]).def(true), - popupElement: PropTypes.any, - direction: PropTypes.string, - getTriggerDOMNode: PropTypes.func, -}; - export default SelectTrigger; diff --git a/components/vc-select/Selector/Input.tsx b/components/vc-select/Selector/Input.tsx index f3474ac0a9..d60a2519bd 100644 --- a/components/vc-select/Selector/Input.tsx +++ b/components/vc-select/Selector/Input.tsx @@ -16,7 +16,7 @@ interface InputProps { autofocus: boolean; autocomplete: string; editable: boolean; - accessibilityIndex: number; + activeDescendantId?: string; value: string; open: boolean; tabindex: number | string; @@ -45,7 +45,7 @@ const Input = defineComponent({ autofocus: PropTypes.looseBool, autocomplete: PropTypes.string, editable: PropTypes.looseBool, - accessibilityIndex: PropTypes.number, + activeDescendantId: PropTypes.string, value: PropTypes.string, open: PropTypes.looseBool, tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), @@ -86,7 +86,7 @@ const Input = defineComponent({ autofocus, autocomplete, editable, - accessibilityIndex, + activeDescendantId, value, onKeydown, onMousedown, @@ -131,7 +131,7 @@ const Input = defineComponent({ 'aria-owns': `${id}_list`, 'aria-autocomplete': 'list', 'aria-controls': `${id}_list`, - 'aria-activedescendant': `${id}_list_${accessibilityIndex}`, + 'aria-activedescendant': activeDescendantId, ...attrs, value: editable ? value : '', readonly: !editable, @@ -178,7 +178,7 @@ const Input = defineComponent({ onOriginBlur && onOriginBlur(args[0]); onBlur && onBlur(args[0]); this.VCSelectContainerEvent?.blur(args[0]); - }, 200); + }, 100); }, }, inputNode.type === 'textarea' ? {} : { type: 'search' }, diff --git a/components/vc-select/Selector/MultipleSelector.tsx b/components/vc-select/Selector/MultipleSelector.tsx index d1aaa9c4f7..3919d1f397 100644 --- a/components/vc-select/Selector/MultipleSelector.tsx +++ b/components/vc-select/Selector/MultipleSelector.tsx @@ -1,12 +1,4 @@ import TransBtn from '../TransBtn'; -import type { - LabelValueType, - RawValueType, - CustomTagProps, - DefaultValueType, - DisplayLabelValueType, -} from '../interface/generator'; -import type { RenderNode } from '../interface'; import type { InnerSelectorProps } from './interface'; import Input from './Input'; import type { Ref, PropType } from 'vue'; @@ -16,6 +8,9 @@ import pickAttrs from '../../_util/pickAttrs'; import PropTypes from '../../_util/vue-types'; import type { VueNode } from '../../_util/type'; import Overflow from '../../vc-overflow'; +import type { DisplayValueType, RenderNode, CustomTagProps, RawValueType } from '../BaseSelect'; +import type { BaseOptionType } from '../Select'; +import useInjectLegacySelectContext from '../../vc-tree-select/LegacyContext'; type SelectorProps = InnerSelectorProps & { // Icon @@ -24,7 +19,7 @@ type SelectorProps = InnerSelectorProps & { // Tags maxTagCount?: number | 'responsive'; maxTagTextLength?: number; - maxTagPlaceholder?: VueNode | ((omittedValues: LabelValueType[]) => VueNode); + maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); tokenSeparators?: string[]; tagRender?: (props: CustomTagProps) => VueNode; onToggleOpen: any; @@ -33,7 +28,7 @@ type SelectorProps = InnerSelectorProps & { choiceTransitionName?: string; // Event - onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onRemove: (value: DisplayValueType) => void; }; const props = { @@ -49,7 +44,7 @@ const props = { showSearch: PropTypes.looseBool, autofocus: PropTypes.looseBool, autocomplete: PropTypes.string, - accessibilityIndex: PropTypes.number, + activeDescendantId: PropTypes.string, tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), removeIcon: PropTypes.any, @@ -58,12 +53,12 @@ const props = { maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxTagTextLength: PropTypes.number, maxTagPlaceholder: PropTypes.any.def( - () => (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, + () => (omittedValues: DisplayValueType[]) => `+ ${omittedValues.length} ...`, ), tagRender: PropTypes.func, onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, - onSelect: PropTypes.func, + onRemove: PropTypes.func, onInputChange: PropTypes.func, onInputPaste: PropTypes.func, onInputKeyDown: PropTypes.func, @@ -85,7 +80,7 @@ const SelectSelector = defineComponent({ const measureRef = ref(); const inputWidth = ref(0); const focused = ref(false); - + const legacyTreeSelectContext = useInjectLegacySelectContext(); const selectionPrefixCls = computed(() => `${props.prefixCls}-selection`); // ===================== Search ====================== @@ -111,6 +106,7 @@ const SelectSelector = defineComponent({ // ===================== Render ====================== // >>> Render Selector Node. Includes Item & Rest function defaultRenderSelector( + title: VueNode, content: VueNode, itemDisabled: boolean, closable?: boolean, @@ -122,9 +118,7 @@ const SelectSelector = defineComponent({ [`${selectionPrefixCls.value}-item-disabled`]: itemDisabled, })} title={ - typeof content === 'string' || typeof content === 'number' - ? content.toString() - : undefined + typeof title === 'string' || typeof title === 'number' ? title.toString() : undefined } > {content} @@ -143,31 +137,38 @@ const SelectSelector = defineComponent({ } function customizeRenderSelector( - value: DefaultValueType, + value: RawValueType, content: VueNode, itemDisabled: boolean, closable: boolean, onClose: (e: MouseEvent) => void, + option: BaseOptionType, ) { const onMouseDown = (e: MouseEvent) => { onPreventMouseDown(e); props.onToggleOpen(!open); }; - + let originData = option; + // For TreeSelect + if (legacyTreeSelectContext.keyEntities) { + originData = legacyTreeSelectContext.keyEntities[value]?.node || {}; + } return ( - + {props.tagRender({ label: content, value, disabled: itemDisabled, closable, onClose, + option: originData, })} ); } - function renderItem({ disabled: itemDisabled, label, value }: DisplayLabelValueType) { + function renderItem(valueItem: DisplayValueType) { + const { disabled: itemDisabled, label, value, option } = valueItem; const closable = !props.disabled && !itemDisabled; let displayLabel = label; @@ -183,24 +184,22 @@ const SelectSelector = defineComponent({ } const onClose = (event?: MouseEvent) => { if (event) event.stopPropagation(); - props.onSelect(value, { selected: false }); + props.onRemove?.(valueItem); }; return typeof props.tagRender === 'function' - ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose) - : defaultRenderSelector(displayLabel, itemDisabled, closable, onClose); + ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose, option) + : defaultRenderSelector(label, displayLabel, itemDisabled, closable, onClose); } - function renderRest(omittedValues: DisplayLabelValueType[]) { - const { - maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, - } = props; + function renderRest(omittedValues: DisplayValueType[]) { + const { maxTagPlaceholder = omittedValues => `+ ${omittedValues.length} ...` } = props; const content = typeof maxTagPlaceholder === 'function' ? maxTagPlaceholder(omittedValues) : maxTagPlaceholder; - return defaultRenderSelector(content, false); + return defaultRenderSelector(content, content, false); } return () => { @@ -214,7 +213,7 @@ const SelectSelector = defineComponent({ disabled, autofocus, autocomplete, - accessibilityIndex, + activeDescendantId, tabindex, onInputChange, onInputPaste, @@ -241,7 +240,7 @@ const SelectSelector = defineComponent({ autofocus={autofocus} autocomplete={autocomplete} editable={inputEditable.value} - accessibilityIndex={accessibilityIndex} + activeDescendantId={activeDescendantId} value={inputValue.value} onKeydown={onInputKeyDown} onMousedown={onInputMouseDown} diff --git a/components/vc-select/Selector/SingleSelector.tsx b/components/vc-select/Selector/SingleSelector.tsx index 0796f4cab6..b67159a982 100644 --- a/components/vc-select/Selector/SingleSelector.tsx +++ b/components/vc-select/Selector/SingleSelector.tsx @@ -3,13 +3,12 @@ import Input from './Input'; import type { InnerSelectorProps } from './interface'; import { Fragment, computed, defineComponent, ref, watch } from 'vue'; import PropTypes from '../../_util/vue-types'; -import { useInjectTreeSelectContext } from '../../vc-tree-select/Context'; import type { VueNode } from '../../_util/type'; +import useInjectLegacySelectContext from '../../vc-tree-select/LegacyContext'; interface SelectorProps extends InnerSelectorProps { inputElement: VueNode; activeValue: string; - backfill?: boolean; } const props = { inputElement: PropTypes.any, @@ -25,7 +24,7 @@ const props = { showSearch: PropTypes.looseBool, autofocus: PropTypes.looseBool, autocomplete: PropTypes.string, - accessibilityIndex: PropTypes.number, + activeDescendantId: PropTypes.string, tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), activeValue: PropTypes.string, backfill: PropTypes.looseBool, @@ -51,7 +50,7 @@ const SingleSelector = defineComponent({ } return inputValue; }); - const treeSelectContext = useInjectTreeSelectContext(); + const legacyTreeSelectContext = useInjectLegacySelectContext(); watch( [combobox, () => props.activeValue], () => { @@ -64,7 +63,7 @@ const SingleSelector = defineComponent({ // Not show text when closed expect combobox mode const hasTextInput = computed(() => - props.mode !== 'combobox' && !props.open ? false : !!inputValue.value, + props.mode !== 'combobox' && !props.open && !props.showSearch ? false : !!inputValue.value, ); const title = computed(() => { @@ -74,6 +73,18 @@ const SingleSelector = defineComponent({ : undefined; }); + const renderPlaceholder = () => { + if (props.values[0]) { + return null; + } + const hiddenStyle = hasTextInput.value ? { visibility: 'hidden' as const } : undefined; + return ( + + {props.placeholder} + + ); + }; + return () => { const { inputElement, @@ -84,9 +95,8 @@ const SingleSelector = defineComponent({ disabled, autofocus, autocomplete, - accessibilityIndex, + activeDescendantId, open, - placeholder, tabindex, onInputKeyDown, onInputMouseDown, @@ -98,13 +108,17 @@ const SingleSelector = defineComponent({ const item = values[0]; let titleNode = null; // custom tree-select title by slot - if (item && treeSelectContext.value.slots) { + + // For TreeSelect + if (item && legacyTreeSelectContext.customSlots) { + const key = item.key ?? item.value; + const originData = legacyTreeSelectContext.keyEntities[key]?.node || {}; titleNode = - treeSelectContext.value.slots[item?.option?.data?.slots?.title] || - treeSelectContext.value.slots.title || + legacyTreeSelectContext.customSlots[originData.slots?.title] || + legacyTreeSelectContext.customSlots.title || item.label; if (typeof titleNode === 'function') { - titleNode = titleNode(item.option?.data || {}); + titleNode = titleNode(originData); } // else if (treeSelectContext.value.slots.titleRender) { // // 因历史 title 是覆盖逻辑,新增 titleRender,所有的 title 都走一遍 titleRender @@ -126,7 +140,7 @@ const SingleSelector = defineComponent({ autofocus={autofocus} autocomplete={autocomplete} editable={inputEditable.value} - accessibilityIndex={accessibilityIndex} + activeDescendantId={activeDescendantId} value={inputValue.value} onKeydown={onInputKeyDown} onMousedown={onInputMouseDown} @@ -145,14 +159,12 @@ const SingleSelector = defineComponent({ {/* Display value */} {!combobox.value && item && !hasTextInput.value && ( - {titleNode} + {titleNode} )} {/* Display placeholder */} - {!item && !hasTextInput.value && ( - {placeholder} - )} + {renderPlaceholder()} ); }; diff --git a/components/vc-select/Selector/index.tsx b/components/vc-select/Selector/index.tsx index 34b29ff12b..6fc30878e4 100644 --- a/components/vc-select/Selector/index.tsx +++ b/components/vc-select/Selector/index.tsx @@ -11,8 +11,8 @@ import KeyCode from '../../_util/KeyCode'; import MultipleSelector from './MultipleSelector'; import SingleSelector from './SingleSelector'; -import type { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator'; -import type { RenderNode, Mode } from '../interface'; +import type { CustomTagProps, DisplayValueType, Mode, RenderNode } from '../BaseSelect'; +import { isValidateOpenKey } from '../utils/keyUtil'; import useLock from '../hooks/useLock'; import type { PropType } from 'vue'; import { defineComponent } from 'vue'; @@ -20,22 +20,22 @@ import createRef from '../../_util/createRef'; import PropTypes from '../../_util/vue-types'; import type { VueNode } from '../../_util/type'; import type { EventHandler } from '../../_util/EventInterface'; +import type { ScrollTo } from '../../vc-virtual-list/List'; export interface SelectorProps { id: string; prefixCls: string; showSearch?: boolean; open: boolean; - /** Display in the Selector value, it's not same as `value` prop */ - values: LabelValueType[]; - multiple: boolean; + values: DisplayValueType[]; + multiple?: boolean; mode: Mode; searchValue: string; activeValue: string; inputElement: VueNode; autofocus?: boolean; - accessibilityIndex: number; + activeDescendantId?: string; tabindex?: number | string; disabled?: boolean; placeholder?: VueNode; @@ -44,7 +44,7 @@ export interface SelectorProps { // Tags maxTagCount?: number | 'responsive'; maxTagTextLength?: number; - maxTagPlaceholder?: VueNode | ((omittedValues: LabelValueType[]) => VueNode); + maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); tagRender?: (props: CustomTagProps) => VueNode; /** Check if `tokenSeparators` contains `\n` or `\r\n` */ @@ -57,7 +57,7 @@ export interface SelectorProps { /** `onSearch` returns go next step boolean to check if need do toggle open */ onSearch: (searchText: string, fromTyping: boolean, isCompositing: boolean) => boolean; onSearchSubmit: (searchText: string) => void; - onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onRemove: (value: DisplayValueType) => void; onInputKeyDown?: (e: KeyboardEvent) => void; /** @@ -66,6 +66,11 @@ export interface SelectorProps { */ domRef: () => HTMLDivElement; } +export interface RefSelectorProps { + focus: () => void; + blur: () => void; + scrollTo?: ScrollTo; +} const Selector = defineComponent({ name: 'Selector', @@ -84,7 +89,7 @@ const Selector = defineComponent({ inputElement: PropTypes.any, autofocus: PropTypes.looseBool, - accessibilityIndex: PropTypes.number, + activeDescendantId: PropTypes.string, tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), disabled: PropTypes.looseBool, placeholder: PropTypes.any, @@ -106,7 +111,7 @@ const Selector = defineComponent({ /** `onSearch` returns go next step boolean to check if need do toggle open */ onSearch: PropTypes.func, onSearchSubmit: PropTypes.func, - onSelect: PropTypes.func, + onRemove: PropTypes.func, onInputKeyDown: { type: Function as PropType }, /** @@ -115,7 +120,7 @@ const Selector = defineComponent({ */ domRef: PropTypes.func, } as any, - setup(props) { + setup(props, { expose }) { const inputRef = createRef(); let compositionStatus = false; @@ -139,7 +144,7 @@ const Selector = defineComponent({ props.onSearchSubmit((event.target as HTMLInputElement).value); } - if (![KeyCode.SHIFT, KeyCode.TAB, KeyCode.BACKSPACE, KeyCode.ESC].includes(which)) { + if (isValidateOpenKey(which)) { props.onToggleOpen(true); } }; @@ -227,57 +232,43 @@ const Selector = defineComponent({ props.onToggleOpen(); } }; - - return { + expose({ focus: () => { inputRef.current.focus(); }, blur: () => { inputRef.current.blur(); }, - onMousedown, - onClick, - onInputPaste, - inputRef, - onInternalInputKeyDown, - onInternalInputMouseDown, - onInputChange, - onInputCompositionEnd, - onInputCompositionStart, - }; - }, - render() { - const { prefixCls, domRef, multiple } = this.$props as SelectorProps; - const { - onMousedown, - onClick, - inputRef, - onInputPaste, - onInternalInputKeyDown, - onInternalInputMouseDown, - onInputChange, - onInputCompositionStart, - onInputCompositionEnd, - } = this as any; - const sharedProps = { - inputRef, - onInputKeyDown: onInternalInputKeyDown, - onInputMouseDown: onInternalInputMouseDown, - onInputChange, - onInputPaste, - onInputCompositionStart, - onInputCompositionEnd, + }); + + return () => { + const { prefixCls, domRef, mode } = props as SelectorProps; + const sharedProps = { + inputRef, + onInputKeyDown: onInternalInputKeyDown, + onInputMouseDown: onInternalInputMouseDown, + onInputChange, + onInputPaste, + onInputCompositionStart, + onInputCompositionEnd, + }; + const selectNode = + mode === 'multiple' || mode === 'tags' ? ( + + ) : ( + + ); + return ( +
+ {selectNode} +
+ ); }; - const selectNode = multiple ? ( - - ) : ( - - ); - return ( -
- {selectNode} -
- ); }, }); diff --git a/components/vc-select/Selector/interface.ts b/components/vc-select/Selector/interface.ts index 49915ad212..18dfd67099 100644 --- a/components/vc-select/Selector/interface.ts +++ b/components/vc-select/Selector/interface.ts @@ -1,8 +1,8 @@ import type { RefObject } from '../../_util/createRef'; -import type { Mode } from '../interface'; -import type { LabelValueType } from '../interface/generator'; + import type { EventHandler } from '../../_util/EventInterface'; import type { VueNode } from '../../_util/type'; +import type { Mode, DisplayValueType } from '../BaseSelect'; export interface InnerSelectorProps { prefixCls: string; @@ -13,10 +13,10 @@ export interface InnerSelectorProps { disabled?: boolean; autofocus?: boolean; autocomplete?: string; - values: LabelValueType[]; + values: DisplayValueType[]; showSearch?: boolean; searchValue: string; - accessibilityIndex: number; + activeDescendantId: string; open: boolean; tabindex?: number | string; onInputKeyDown: EventHandler; diff --git a/components/vc-select/TransBtn.tsx b/components/vc-select/TransBtn.tsx index a91b8e67dd..6c95ce1d98 100644 --- a/components/vc-select/TransBtn.tsx +++ b/components/vc-select/TransBtn.tsx @@ -1,10 +1,11 @@ import type { FunctionalComponent } from 'vue'; import type { VueNode } from '../_util/type'; import PropTypes from '../_util/vue-types'; +import type { RenderNode } from './BaseSelect'; export interface TransBtnProps { class: string; - customizeIcon: VueNode | ((props?: any) => VueNode); + customizeIcon: RenderNode; customizeIconProps?: any; onMousedown?: (payload: MouseEvent) => void; onClick?: (payload: MouseEvent) => void; diff --git a/components/vc-select/assets/index.less b/components/vc-select/assets/index.less deleted file mode 100644 index cbd4a13093..0000000000 --- a/components/vc-select/assets/index.less +++ /dev/null @@ -1,345 +0,0 @@ -@select-prefix: ~'rc-select'; - -* { - box-sizing: border-box; -} - -.search-input-without-border() { - .@{select-prefix}-selection-search-input { - border: none; - outline: none; - background: rgba(255, 0, 0, 0.2); - width: 100%; - } -} - -.@{select-prefix} { - display: inline-block; - font-size: 12px; - width: 100px; - position: relative; - - &-disabled { - &, - & input { - cursor: not-allowed; - } - - .@{select-prefix}-selector { - opacity: 0.3; - } - } - - &-show-arrow&-loading { - .@{select-prefix}-arrow { - &-icon::after { - box-sizing: border-box; - width: 12px; - height: 12px; - border-radius: 100%; - border: 2px solid #999; - border-top-color: transparent; - border-bottom-color: transparent; - transform: none; - margin-top: 4px; - - animation: rcSelectLoadingIcon 0.5s infinite; - } - } - } - - // ============== Selector =============== - .@{select-prefix}-selection-placeholder { - opacity: 0.4; - } - - // ============== Search =============== - .@{select-prefix}-selection-search-input { - appearance: none; - - &::-webkit-search-cancel-button { - display: none; - appearance: none; - } - } - - // --------------- Single ---------------- - &-single { - .@{select-prefix}-selector { - display: flex; - position: relative; - - .@{select-prefix}-selection-search { - width: 100%; - - &-input { - width: 100%; - } - } - - .@{select-prefix}-selection-item, - .@{select-prefix}-selection-placeholder { - position: absolute; - top: 1px; - left: 3px; - pointer-events: none; - } - } - - // Not customize - &:not(.@{select-prefix}-customize-input) { - .@{select-prefix}-selector { - padding: 1px; - border: 1px solid #000; - - .search-input-without-border(); - } - } - } - - // -------------- Multiple --------------- - &-multiple .@{select-prefix}-selector { - display: flex; - flex-wrap: wrap; - padding: 1px; - border: 1px solid #000; - - .@{select-prefix}-selection-item { - flex: none; - background: #bbb; - border-radius: 4px; - margin-right: 2px; - padding: 0 8px; - - &-disabled { - cursor: not-allowed; - opacity: 0.5; - } - } - - .@{select-prefix}-selection-search { - position: relative; - - &-input, - &-mirror { - padding: 1px; - font-family: system-ui; - } - - &-mirror { - position: absolute; - z-index: 999; - white-space: nowrap; - position: none; - left: 0; - top: 0; - visibility: hidden; - } - } - - .search-input-without-border(); - } - - // ================ Icons ================ - &-allow-clear { - &.@{select-prefix}-multiple .@{select-prefix}-selector { - padding-right: 20px; - } - - .@{select-prefix}-clear { - position: absolute; - right: 20px; - top: 0; - } - } - - &-show-arrow { - &.@{select-prefix}-multiple .@{select-prefix}-selector { - padding-right: 20px; - } - - .@{select-prefix}-arrow { - pointer-events: none; - position: absolute; - right: 5px; - top: 0; - - &-icon::after { - content: ''; - border: 5px solid transparent; - width: 0; - height: 0; - display: inline-block; - border-top-color: #999; - transform: translateY(5px); - } - } - } - - // =============== Focused =============== - &-focused { - .@{select-prefix}-selector { - border-color: blue !important; - } - } - - // ============== Dropdown =============== - &-dropdown { - border: 1px solid green; - min-height: 100px; - position: absolute; - background: #fff; - - &-hidden { - display: none; - } - } - - // =============== Option ================ - &-item { - font-size: 16px; - line-height: 1.5; - padding: 4px 16px; - - // >>> Group - &-group { - color: #999; - font-weight: bold; - font-size: 80%; - } - - // >>> Option - &-option { - position: relative; - - &-grouped { - padding-left: 24px; - } - - .@{select-prefix}-item-option-state { - position: absolute; - right: 0; - top: 4px; - pointer-events: none; - } - - // ------- Active ------- - &-active { - background: green; - } - - // ------ Disabled ------ - &-disabled { - color: #999; - } - } - - // >>> Empty - &-empty { - text-align: center; - color: #999; - } - } -} - -.@{select-prefix}-selection__choice-zoom { - transition: all 0.3s; -} - -.@{select-prefix}-selection__choice-zoom-appear { - opacity: 0; - transform: scale(0.5); - - &&-active { - opacity: 1; - transform: scale(1); - } -} -.@{select-prefix}-selection__choice-zoom-leave { - opacity: 1; - transform: scale(1); - - &&-active { - opacity: 0; - transform: scale(0.5); - } -} - -.effect() { - animation-duration: 0.3s; - animation-fill-mode: both; - transform-origin: 0 0; -} - -.@{select-prefix}-dropdown { - &-slide-up-enter, - &-slide-up-appear { - .effect(); - opacity: 0; - animation-timing-function: cubic-bezier(0.08, 0.82, 0.17, 1); - animation-play-state: paused; - } - - &-slide-up-leave { - .effect(); - opacity: 1; - animation-timing-function: cubic-bezier(0.6, 0.04, 0.98, 0.34); - animation-play-state: paused; - } - - &-slide-up-enter&-slide-up-enter-active&-placement-bottomLeft, - &-slide-up-appear&-slide-up-appear-active&-placement-bottomLeft { - animation-name: rcSelectDropdownSlideUpIn; - animation-play-state: running; - } - - &-slide-up-leave&-slide-up-leave-active&-placement-bottomLeft { - animation-name: rcSelectDropdownSlideUpOut; - animation-play-state: running; - } - - &-slide-up-enter&-slide-up-enter-active&-placement-topLeft, - &-slide-up-appear&-slide-up-appear-active&-placement-topLeft { - animation-name: rcSelectDropdownSlideDownIn; - animation-play-state: running; - } - - &-slide-up-leave&-slide-up-leave-active&-placement-topLeft { - animation-name: rcSelectDropdownSlideDownOut; - animation-play-state: running; - } -} - -@keyframes rcSelectDropdownSlideUpIn { - 0% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(0); - } - 100% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } -} -@keyframes rcSelectDropdownSlideUpOut { - 0% { - opacity: 1; - transform-origin: 0% 0%; - transform: scaleY(1); - } - 100% { - opacity: 0; - transform-origin: 0% 0%; - transform: scaleY(0); - } -} - -@keyframes rcSelectLoadingIcon { - 0% { - transform: rotate(0); - } - 100% { - transform: rotate(360deg); - } -} diff --git a/components/vc-select/examples/combobox.jsx b/components/vc-select/examples/combobox.jsx deleted file mode 100644 index 9e9de64853..0000000000 --- a/components/vc-select/examples/combobox.jsx +++ /dev/null @@ -1,136 +0,0 @@ -import createRef from '../../_util/createRef'; -/* eslint-disable no-console */ -import Select, { Option } from '..'; -import '../assets/index.less'; -import { nextTick } from 'vue'; - -const Combobox = { - data() { - this.textareaRef = createRef(); - - this.timeoutId; - return { - disabled: false, - value: '', - options: [], - }; - }, - - mounted() { - nextTick(() => { - console.log('Ref:', this.textareaRef.current); - }); - }, - methods: { - onChange(value, option) { - console.log('onChange', value, option); - - this.value = value; - }, - - onKeyDown(e) { - const { value } = this; - if (e.keyCode === 13) { - console.log('onEnter', value); - } - }, - - onSelect(v, option) { - console.log('onSelect', v, option); - }, - - onSearch(text) { - console.log('onSearch:', text); - }, - - onAsyncChange(value) { - window.clearTimeout(this.timeoutId); - console.log(value); - this.options = []; - //const value = String(Math.random()); - this.timeoutId = window.setTimeout(() => { - this.options = [{ value }, { value: `${value}-${value}` }]; - }, 1000); - }, - - toggleDisabled() { - const { disabled } = this; - - this.disabled = !disabled; - }, - }, - - render() { - const { value, disabled } = this; - return ( -
-

combobox

-

- - -

-
- - -

Customize Input Element

-