refactor: select

refactor-cascader
tangjinzhou 2022-01-11 17:59:38 +08:00
parent 1c508d61fb
commit d027f286e3
42 changed files with 786 additions and 1627 deletions

View File

@ -31,11 +31,6 @@ The label of the selected item will be packed as an object for passing to the on
import type { SelectProps } from 'ant-design-vue';
import { defineComponent, ref } from 'vue';
interface Value {
value?: string;
label?: string;
}
export default defineComponent({
setup() {
const options = ref<SelectProps['options']>([
@ -48,11 +43,11 @@ export default defineComponent({
label: 'Lucy (101)',
},
]);
const handleChange = (value: Value) => {
const handleChange: SelectProps['onChange'] = value => {
console.log(value); // { key: "lucy", label: "Lucy (101)" }
};
return {
value: ref<Value>({ value: 'lucy' }),
value: ref({ value: 'lucy' }),
options,
handleChange,
};

View File

@ -10,10 +10,13 @@ title:
使用 `optionLabelProp` 指定回填到选择框的 `Option` 属性
或者使用 `tagRender` 插槽自定义渲染节点
## en-US
Spacified the prop name of Option which will be rendered in select box.
or use `tagRender` slot for custom rendering of tags.
</docs>
<template>
@ -23,7 +26,7 @@ Spacified the prop name of Option which will be rendered in select box.
mode="multiple"
style="width: 100%"
placeholder="select one country"
option-label-prop="label"
option-label-prop="children"
>
<a-select-option value="china" label="China">
<span role="img" aria-label="China">🇨🇳</span>
@ -58,6 +61,29 @@ Spacified the prop name of Option which will be rendered in select box.
</a-select>
<span>Note: v-slot:option support from v2.2.5</span>
</a-space>
<br />
<br />
<a-space direction="vertical" style="width: 100%">
<a-select
v-model:value="value"
mode="multiple"
style="width: 100%"
placeholder="select one country"
:options="options"
>
<template #option="{ value: val, label, icon }">
<span role="img" :aria-label="val">{{ icon }}</span>
&nbsp;&nbsp;{{ label }}
</template>
<template #tagRender="{ value: val, label, closable, onClose, option }">
<a-tag :closable="closable" style="margin-right: 3px" @close="onClose">
{{ label }}&nbsp;&nbsp;
<span role="img" :aria-label="val">{{ option.icon }}</span>
</a-tag>
</template>
</a-select>
<span>Note: v-slot:tagRender support from v3.0</span>
</a-space>
</template>
<script lang="ts">
import { defineComponent, ref, watch } from 'vue';
@ -91,7 +117,6 @@ export default defineComponent({
watch(value, val => {
console.log(`selected:`, val);
});
return {
options,
value,

View File

@ -19,7 +19,7 @@ The height of the input field for the select defaults to 32px. If size is set to
<template>
<a-radio-group v-model:value="size">
<a-radio-button value="large">Large</a-radio-button>
<a-radio-button value="default">Default</a-radio-button>
<a-radio-button value="middle">Middle</a-radio-button>
<a-radio-button value="small">Small</a-radio-button>
</a-radio-group>
<br />
@ -51,6 +51,7 @@ The height of the input field for the select defaults to 32px. If size is set to
</a-space>
</template>
<script lang="ts">
import type { SizeType } from 'ant-design-vue/es/config-provider';
import { defineComponent, ref } from 'vue';
export default defineComponent({
setup() {
@ -60,7 +61,7 @@ export default defineComponent({
return {
popupScroll,
size: ref('default'),
size: ref<SizeType>('middle'),
value1: ref('a1'),
value2: ref(['a1', 'b2']),
value3: ref(['a1', 'b2']),

View File

@ -55,6 +55,7 @@ Select component to select value from options.
| clearIcon | The custom clear icon | VNode \| slot | - | |
| menuItemSelectedIcon | The custom menuItemSelected icon | VNode \| slot | - | |
| tokenSeparators | Separator used to tokenize on tag/multiple mode | string\[] | | |
| tagRender | Customize tag render, only applies when `mode` is set to `multiple` or `tags` | slot \| (props) => any | - | |
| value(v-model) | Current selected option. | string\|number\|string\[]\|number\[] | - | |
| options | Data of the selectOption, manual construction work is no longer needed if this property has been set | array&lt;{value, label, [disabled, key, title]}> | \[] | |
| option | custom render option by slot | v-slot:option="{value, label, [disabled, key, title]}" | - | 2.2.5 |

View File

@ -1,26 +1,28 @@
import type { App, PropType, Plugin, ExtractPropTypes } from 'vue';
import { computed, defineComponent, ref } from 'vue';
import classNames from '../_util/classNames';
import RcSelect, { selectProps as vcSelectProps, Option, OptGroup } from '../vc-select';
import type { OptionProps as OptionPropsType } from '../vc-select/Option';
import type { BaseSelectRef } from '../vc-select2';
import RcSelect, { selectProps as vcSelectProps, Option, OptGroup } from '../vc-select2';
import type { BaseOptionType, DefaultOptionType } from '../vc-select2/Select';
import type { OptionProps } from '../vc-select2/Option';
import getIcons from './utils/iconUtil';
import PropTypes from '../_util/vue-types';
import { tuple } from '../_util/type';
import useConfigInject from '../_util/hooks/useConfigInject';
import omit from '../_util/omit';
import { useInjectFormItemContext } from '../form/FormItemContext';
import { getTransitionName } from '../_util/transition';
import type { SizeType } from '../config-provider';
import { initDefaultProps } from '../_util/props-util';
type RawValue = string | number;
export type OptionProps = OptionPropsType;
export type OptionType = typeof Option;
export type { OptionProps, BaseSelectRef as RefSelectProps, BaseOptionType, DefaultOptionType };
export interface LabeledValue {
key?: string;
value: RawValue;
label: any;
label?: any;
}
export type SelectValue = RawValue | RawValue[] | LabeledValue | LabeledValue[] | undefined;
@ -35,23 +37,28 @@ export const selectProps = () => ({
notFoundContent: PropTypes.any,
suffixIcon: PropTypes.any,
itemIcon: PropTypes.any,
size: PropTypes.oneOf(tuple('small', 'middle', 'large', 'default')),
mode: PropTypes.oneOf(tuple('multiple', 'tags', 'SECRET_COMBOBOX_MODE_DO_NOT_USE')),
bordered: PropTypes.looseBool.def(true),
transitionName: PropTypes.string,
choiceTransitionName: PropTypes.string.def(''),
size: String as PropType<SizeType>,
mode: String as PropType<'multiple' | 'tags' | 'SECRET_COMBOBOX_MODE_DO_NOT_USE'>,
bordered: { type: Boolean, default: true },
transitionName: String,
choiceTransitionName: { type: String, default: '' },
'onUpdate:value': Function as PropType<(val: SelectValue) => void>,
});
export type SelectProps = Partial<ExtractPropTypes<ReturnType<typeof selectProps>>>;
const SECRET_COMBOBOX_MODE_DO_NOT_USE = 'SECRET_COMBOBOX_MODE_DO_NOT_USE';
const Select = defineComponent({
name: 'ASelect',
Option,
OptGroup,
inheritAttrs: false,
props: selectProps(),
SECRET_COMBOBOX_MODE_DO_NOT_USE: 'SECRET_COMBOBOX_MODE_DO_NOT_USE',
emits: ['change', 'update:value', 'blur'],
props: initDefaultProps(selectProps(), {
listHeight: 256,
listItemHeight: 24,
}),
SECRET_COMBOBOX_MODE_DO_NOT_USE,
// emits: ['change', 'update:value', 'blur'],
slots: [
'notFoundContent',
'suffixIcon',
@ -61,20 +68,21 @@ const Select = defineComponent({
'dropdownRender',
'option',
'placeholder',
'tagRender',
],
setup(props, { attrs, emit, slots, expose }) {
const selectRef = ref();
const selectRef = ref<BaseSelectRef>();
const formItemContext = useInjectFormItemContext();
const focus = () => {
if (selectRef.value) {
selectRef.value.focus();
}
selectRef.value?.focus();
};
const blur = () => {
if (selectRef.value) {
selectRef.value.blur();
}
selectRef.value?.blur();
};
const scrollTo: BaseSelectRef['scrollTo'] = arg => {
selectRef.value?.scrollTo(arg);
};
const mode = computed(() => {
@ -84,7 +92,7 @@ const Select = defineComponent({
return undefined;
}
if (mode === Select.SECRET_COMBOBOX_MODE_DO_NOT_USE) {
if (mode === SECRET_COMBOBOX_MODE_DO_NOT_USE) {
return 'combobox';
}
@ -103,19 +111,21 @@ const Select = defineComponent({
[`${prefixCls.value}-borderless`]: !props.bordered,
}),
);
const triggerChange = (...args: any[]) => {
const triggerChange: SelectProps['onChange'] = (...args) => {
emit('update:value', args[0]);
emit('change', ...args);
formItemContext.onFieldChange();
};
const handleBlur = (e: InputEvent) => {
const handleBlur: SelectProps['onBlur'] = e => {
emit('blur', e);
formItemContext.onFieldBlur();
};
expose({
blur,
focus,
scrollTo,
});
const isMultiple = computed(() => mode.value === 'multiple' || mode.value === 'tags');
return () => {
const {
notFoundContent,
@ -131,8 +141,6 @@ const Select = defineComponent({
const { renderEmpty, getPopupContainer: getContextPopupContainer } = configProvider;
const isMultiple = mode.value === 'multiple' || mode.value === 'tags';
// ===================== Empty =====================
let mergedNotFound: any;
if (notFoundContent !== undefined) {
@ -149,7 +157,7 @@ const Select = defineComponent({
const { suffixIcon, itemIcon, removeIcon, clearIcon } = getIcons(
{
...props,
multiple: isMultiple,
multiple: isMultiple.value,
prefixCls: prefixCls.value,
},
slots,
@ -195,9 +203,9 @@ const Select = defineComponent({
dropdownRender={selectProps.dropdownRender || slots.dropdownRender}
v-slots={{ option: slots.option }}
transitionName={transitionName.value}
>
{slots.default?.()}
</RcSelect>
children={slots.default?.()}
tagRender={props.tagRender || slots.tagRender}
></RcSelect>
);
};
},

View File

@ -56,6 +56,7 @@ cover: https://gw.alipayobjects.com/zos/alicdn/_0XzgOis7/Select.svg
| clearIcon | 自定义的多选框清空图标 | VNode \| slot | - | |
| menuItemSelectedIcon | 自定义当前选中的条目图标 | VNode \| slot | - | |
| tokenSeparators | 在 tags 和 multiple 模式下自动分词的分隔符 | string\[] | | |
| tagRender | 自定义 tag 内容 render仅在 `mode``multiple``tags` 时生效 | slot \| (props) => any | - | 3.0 |
| value(v-model) | 指定当前选中的条目 | string\|string\[]\|number\|number\[] | - | |
| options | options 数据,如果设置则不需要手动构造 selectOption 节点 | array&lt;{value, label, [disabled, key, title]}> | \[] | |
| option | 通过 option 插槽,自定义节点 | v-slot:option="{value, label, [disabled, key, title]}" | - | 2.2.5 |

View File

@ -1,7 +1,6 @@
@import '../../style/themes/index';
@import '../../style/mixins/index';
@import '../../input/style/mixin';
@import './single';
@import './multiple';
@ -59,6 +58,7 @@
&::-webkit-search-cancel-button {
display: none;
/* stylelint-disable-next-line property-no-vendor-prefix */
-webkit-appearance: none;
}
}
@ -83,6 +83,7 @@
&-selection-item {
flex: 1;
overflow: hidden;
font-weight: normal;
white-space: nowrap;
text-overflow: ellipsis;
@ -117,7 +118,7 @@
&-arrow {
.iconfont-mixin();
position: absolute;
top: 53%;
top: 50%;
right: @control-padding-horizontal - 1px;
width: @font-size-sm;
height: @font-size-sm;
@ -167,9 +168,11 @@
opacity: 0;
transition: color 0.3s ease, opacity 0.15s ease;
text-rendering: auto;
&::before {
display: block;
}
&:hover {
color: @text-color-secondary;
}
@ -286,6 +289,9 @@
}
&-disabled {
&.@{select-prefix-cls}-item-option-selected {
background-color: @select-multiple-disabled-background;
}
color: @disabled-color;
cursor: not-allowed;
}

View File

@ -1,4 +1,5 @@
@import './index';
@import (reference) '../../style/themes/index';
@select-prefix-cls: ~'@{ant-prefix}-select';
@select-overflow-prefix-cls: ~'@{select-prefix-cls}-selection-overflow';
@select-multiple-item-border-width: 1px;
@ -128,8 +129,6 @@
.@{select-prefix-cls}-selection-search {
position: relative;
max-width: 100%;
margin-top: @select-multiple-item-spacing-half;
margin-bottom: @select-multiple-item-spacing-half;
margin-inline-start: @input-padding-horizontal-base - @input-padding-vertical-base;
&-input,

View File

@ -1,4 +1,5 @@
@import './index';
@import (reference) '../../style/themes/index';
@select-prefix-cls: ~'@{ant-prefix}-select';
@selection-item-padding: ceil(@font-size-base * 1.25);
@ -39,14 +40,15 @@
}
.@{select-prefix-cls}-selection-placeholder {
transition: none;
pointer-events: none;
}
// For common baseline align
&::after,
// For '' value baseline align
/* For '' value baseline align */
.@{select-prefix-cls}-selection-item::after,
// For undefined value baseline align
/* For undefined value baseline align */
.@{select-prefix-cls}-selection-placeholder::after {
display: inline-block;
width: 0;

View File

@ -1,162 +0,0 @@
/**
* To match accessibility requirement, we always provide an input in the component.
* 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:
* - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions
*
* New api:
* - listHeight
* - listItemHeight
* - component
*
* Remove deprecated api:
* - multiple
* - tags
* - combobox
* - firstActiveValue
* - dropdownMenuStyle
* - openClassName (Not list in api)
*
* Update:
* - `backfill` only support `combobox` mode
* - `combobox` mode not support `labelInValue` since it's meaningless
* - `getInputElement` only support `combobox` mode
* - `onChange` return OptionData instead of ReactNode
* - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode
* - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option
* - `combobox` mode not support `optionLabelProp`
*/
import BaseSelect, { baseSelectPropsWithoutPrivate, isMultiple } from './BaseSelect';
import type {
DisplayValueType,
RenderNode,
BaseSelectRef,
BaseSelectPropsWithoutPrivate,
BaseSelectProps,
} from './BaseSelect';
import OptionList from './OptionList';
import Option from './Option';
import OptGroup from './OptGroup';
import useOptions from './hooks/useOptions';
import SelectContext from './SelectContext';
import useId from './hooks/useId';
import useRefFunc from './hooks/useRefFunc';
import { fillFieldNames, flattenOptions, injectPropsWithOption } from './utils/valueUtil';
import warningProps from './utils/warningPropsUtil';
import { toArray } from './utils/commonUtil';
import useFilterOptions from './hooks/useFilterOptions';
import useCache from './hooks/useCache';
import type { Key } from '../_util/type';
import { defineComponent } from 'vue';
import type { ExtractPropTypes, PropType } from 'vue';
import PropTypes from '../_util/vue-types';
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 FilterFunc<OptionType> = (inputValue: string, option?: OptionType) => boolean;
export interface FieldNames {
value?: string;
label?: string;
options?: string;
}
export interface BaseOptionType {
disabled?: boolean;
[name: string]: any;
}
export interface DefaultOptionType extends BaseOptionType {
label: any;
value?: string | number | null;
children?: Omit<DefaultOptionType, 'children'>[];
}
export type SelectHandler<ValueType = any, OptionType extends BaseOptionType = DefaultOptionType> =
| ((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<FieldNames>,
// >>> 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<SelectHandler<ValueType, OptionType>>,
onDeselect: Function as PropType<SelectHandler<ValueType, OptionType>>,
// >>> Options
/**
* In Select, `false` means do nothing.
* In TreeSelect, `false` will highlight match item.
* It's by design.
*/
filterOption: {
type: [Boolean, Function] as PropType<boolean | FilterFunc<OptionType>>,
default: undefined,
},
filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>,
optionFilterProp: String,
optionLabelProp: String,
children: PropTypes.any,
options: Array as PropType<OptionType[]>,
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>,
};
}
export type SelectProps = Partial<ExtractPropTypes<ReturnType<typeof selectProps>>>;
export default defineComponent({});

File diff suppressed because it is too large Load Diff

View File

@ -1,73 +0,0 @@
import type { VueNode } from '../../_util/type';
export type SelectSource = 'option' | 'selection' | 'input';
export const INTERNAL_PROPS_MARK = 'RC_SELECT_INTERNAL_PROPS_MARK';
// =================================== Shared Type ===================================
export type Key = string | number;
export type RawValueType = string | number | null;
export interface LabelValueType extends Record<string, any> {
key?: Key;
value?: RawValueType;
label?: any;
isCacheable?: boolean;
}
export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[];
export interface DisplayLabelValueType extends LabelValueType {
disabled?: boolean;
}
export type SingleType<MixType> = MixType extends (infer Single)[] ? Single : MixType;
export type OnClear = () => any;
export type CustomTagProps = {
label: any;
value: DefaultValueType;
disabled: boolean;
onClose: (event?: MouseEvent) => void;
closable: boolean;
};
// ==================================== Generator ====================================
export type GetLabeledValue<FOT extends FlattenOptionsType> = (
value: RawValueType,
config: {
options: FOT;
prevValueMap: Map<RawValueType, LabelValueType>;
labelInValue: boolean;
optionLabelProp: string;
},
) => LabelValueType;
export type FilterOptions<OptionsType extends object[]> = (
searchValue: string,
options: OptionsType,
/** Component props, since Select & TreeSelect use different prop name, use any here */
config: {
optionFilterProp: string;
filterOption: boolean | FilterFunc<OptionsType[number]>;
},
) => OptionsType;
export type FilterFunc<OptionType> = (inputValue: string, option?: OptionType) => boolean;
export type FlattenOptionsType<OptionType = object> = {
key: Key;
data: OptionType;
label?: any;
value?: RawValueType;
/** Used for customize data */
[name: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}[];
export type DropdownObject = {
menuNode?: VueNode;
props?: Record<string, any>;
};
export type DropdownRender = (opt?: DropdownObject) => VueNode;

View File

@ -1,62 +0,0 @@
import type { VueNode } from '../../_util/type';
import type { VNode, CSSProperties } from 'vue';
import type { Key, RawValueType } from './generator';
export type RenderDOMFunc = (props: any) => HTMLElement;
export type RenderNode = VueNode | ((props: any) => VueNode);
export type Mode = 'multiple' | 'tags' | 'combobox';
// ======================== Option ========================
export interface FieldNames {
value?: string;
label?: string;
options?: string;
}
export type OnActiveValue = (
active: RawValueType,
index: number,
info?: { source?: 'keyboard' | 'mouse' },
) => void;
export interface OptionCoreData {
key?: Key;
disabled?: boolean;
value?: Key;
title?: string;
class?: string;
style?: CSSProperties;
label?: VueNode;
/** @deprecated Only works when use `children` as option data */
children?: VNode[] | JSX.Element[];
}
export interface OptionData extends OptionCoreData {
/** Save for customize data */
[prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export interface OptionGroupData {
key?: Key;
label?: VueNode;
options: OptionData[];
class?: string;
style?: CSSProperties;
/** Save for customize data */
[prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export type OptionsType = (OptionData | OptionGroupData)[];
export interface FlattenOptionData {
group?: boolean;
groupOption?: boolean;
key: string | number;
data: OptionData | OptionGroupData;
label?: any;
value?: RawValueType;
}

View File

@ -22,6 +22,7 @@ import {
getCurrentInstance,
onBeforeUnmount,
onMounted,
provide,
ref,
toRefs,
watch,
@ -36,6 +37,7 @@ import { toReactive } from '../_util/toReactive';
import classNames from '../_util/classNames';
import OptionList from './OptionList';
import createRef from '../_util/createRef';
import type { BaseOptionType } from './Select';
const DEFAULT_OMIT_PROPS = [
'value',
@ -50,6 +52,8 @@ const DEFAULT_OMIT_PROPS = [
'onInputKeyDown',
'onPopupScroll',
'tabindex',
'OptionList',
'notFoundContent',
] as const;
export type RenderNode = VueNode | ((props: any) => VueNode);
@ -74,20 +78,22 @@ export type CustomTagProps = {
disabled: boolean;
onClose: (event?: MouseEvent) => void;
closable: boolean;
option: BaseOptionType;
};
export interface DisplayValueType {
key?: Key;
value?: RawValueType;
label?: any;
disabled: boolean;
disabled?: boolean;
option?: BaseOptionType;
}
export interface BaseSelectRef {
export type BaseSelectRef = {
focus: () => void;
blur: () => void;
scrollTo: ScrollTo;
}
};
const baseSelectPrivateProps = () => {
return {
@ -251,7 +257,7 @@ export default defineComponent({
name: 'BaseSelect',
inheritAttrs: false,
props: initDefaultProps(baseSelectProps(), { showAction: [], notFoundContent: 'Not Found' }),
setup(props, { attrs, expose }) {
setup(props, { attrs, expose, slots }) {
const multiple = computed(() => isMultiple(props.mode));
const mergedShowSearch = computed(() =>
@ -533,7 +539,10 @@ export default defineComponent({
props.onBlur(...args);
}
};
provide('VCSelectContainerEvent', {
focus: onContainerFocus,
blur: onContainerBlur,
});
const activeTimeoutIds: any[] = [];
onMounted(() => {
@ -743,7 +752,7 @@ export default defineComponent({
}
// =========================== OptionList ===========================
const optionList = <OptionList ref={listRef} />;
const optionList = <OptionList ref={listRef} v-slots={{ option: slots.option }} />;
// ============================= Select =============================
const mergedClassName = classNames(prefixCls, attrs.class, {
@ -822,14 +831,14 @@ export default defineComponent({
} else {
renderNode = (
<div
class={mergedClassName}
{...domProps}
class={mergedClassName}
ref={containerRef}
onMousedown={onInternalMouseDown}
onKeydown={onInternalKeyDown}
onKeyup={onInternalKeyUp}
onFocus={onContainerFocus}
onBlur={onContainerBlur}
// onFocus={onContainerFocus}
// onBlur={onContainerBlur}
>
{mockFocused && !mergedOpen.value && (
<span

View File

@ -278,7 +278,7 @@ const OptionList = defineComponent({
// Group
if (group) {
return (
<div class={classNames(itemPrefixCls, `${itemPrefixCls.value}-group`)}>
<div class={classNames(itemPrefixCls.value, `${itemPrefixCls.value}-group`)}>
{renderOption ? renderOption(data) : label !== undefined ? label : key}
</div>
);
@ -298,12 +298,18 @@ const OptionList = defineComponent({
const selected = rawValues.has(value);
const optionPrefixCls = `${itemPrefixCls.value}-option`;
const optionClassName = classNames(itemPrefixCls, optionPrefixCls, cls, className, {
[`${optionPrefixCls}-grouped`]: groupOption,
[`${optionPrefixCls}-active`]: activeIndex === itemIndex && !disabled,
[`${optionPrefixCls}-disabled`]: disabled,
[`${optionPrefixCls}-selected`]: selected,
});
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);

View File

@ -0,0 +1,642 @@
/**
* To match accessibility requirement, we always provide an input in the component.
* 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:
* - keyboard: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Roles/listbox_role#Keyboard_interactions
*
* New api:
* - listHeight
* - listItemHeight
* - component
*
* Remove deprecated api:
* - multiple
* - tags
* - combobox
* - firstActiveValue
* - dropdownMenuStyle
* - openClassName (Not list in api)
*
* Update:
* - `backfill` only support `combobox` mode
* - `combobox` mode not support `labelInValue` since it's meaningless
* - `getInputElement` only support `combobox` mode
* - `onChange` return OptionData instead of ReactNode
* - `filterOption` `onChange` `onSelect` accept OptionData instead of ReactNode
* - `combobox` mode trigger `onChange` will get `undefined` if no `value` match in Option
* - `combobox` mode not support `optionLabelProp`
*/
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 { 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 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 FilterFunc<OptionType> = (inputValue: string, option?: OptionType) => boolean;
export interface FieldNames {
value?: string;
label?: string;
options?: string;
}
export interface BaseOptionType {
disabled?: boolean;
[name: string]: any;
}
export interface DefaultOptionType extends BaseOptionType {
label?: any;
value?: string | number | null;
children?: Omit<DefaultOptionType, 'children'>[];
}
export type SelectHandler<ValueType = any, OptionType extends BaseOptionType = DefaultOptionType> =
| ((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<FieldNames>,
// >>> 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<SelectHandler<ValueType, OptionType>>,
onDeselect: Function as PropType<SelectHandler<ValueType, OptionType>>,
// >>> Options
/**
* In Select, `false` means do nothing.
* In TreeSelect, `false` will highlight match item.
* It's by design.
*/
filterOption: {
type: [Boolean, Function] as PropType<boolean | FilterFunc<OptionType>>,
default: undefined,
},
filterSort: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>,
optionFilterProp: String,
optionLabelProp: String,
options: Array as PropType<OptionType[]>,
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<VueNode[]>,
};
}
export type SelectProps = Partial<ExtractPropTypes<ReturnType<typeof selectProps>>>;
function isRawValue(value: DraftValueType): value is RawValueType {
return !value || typeof value !== 'object';
}
export default defineComponent({
name: 'Select',
inheritAttrs: false,
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<string>(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<RawValueType>([...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<RawValueType>([...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<BaseSelectRef>();
expose({
focus() {
selectRef.value?.focus();
},
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 (
<BaseSelect
{...pickProps.value}
{...attrs}
// >>> 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}
/>
);
};
},
});

View File

@ -9,6 +9,7 @@ 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';
type SelectorProps = InnerSelectorProps & {
// Icon
@ -140,12 +141,12 @@ const SelectSelector = defineComponent<SelectorProps>({
itemDisabled: boolean,
closable: boolean,
onClose: (e: MouseEvent) => void,
option: BaseOptionType,
) {
const onMouseDown = (e: MouseEvent) => {
onPreventMouseDown(e);
props.onToggleOpen(!open);
};
return (
<span onMousedown={onMouseDown}>
{props.tagRender({
@ -154,13 +155,14 @@ const SelectSelector = defineComponent<SelectorProps>({
disabled: itemDisabled,
closable,
onClose,
option,
})}
</span>
);
}
function renderItem(valueItem: DisplayValueType) {
const { disabled: itemDisabled, label, value } = valueItem;
const { disabled: itemDisabled, label, value, option } = valueItem;
const closable = !props.disabled && !itemDisabled;
let displayLabel = label;
@ -180,7 +182,7 @@ const SelectSelector = defineComponent<SelectorProps>({
};
return typeof props.tagRender === 'function'
? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose)
? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose, option)
: defaultRenderSelector(label, displayLabel, itemDisabled, closable, onClose);
}

View File

@ -1,5 +1,5 @@
import type { Ref } from 'vue';
import { computed } from 'vue';
import { shallowRef, watchEffect } from 'vue';
import type { FieldNames, RawValueType } from '../Select';
import { convertChildrenToData } from '../utils/legacyUtil';
@ -12,35 +12,40 @@ export default function useOptions<OptionType>(
children: Ref<any>,
fieldNames: Ref<FieldNames>,
) {
return computed(() => {
let mergedOptions = options.value;
const mergedOptions = shallowRef();
const valueOptions = shallowRef();
const labelOptions = shallowRef();
watchEffect(() => {
let newOptions = options.value;
const childrenAsData = !options.value;
if (childrenAsData) {
mergedOptions = convertChildrenToData(children.value);
newOptions = convertChildrenToData(children.value);
}
const valueOptions = new Map<RawValueType, OptionType>();
const labelOptions = new Map<any, OptionType>();
const newValueOptions = new Map<RawValueType, OptionType>();
const newLabelOptions = new Map<any, OptionType>();
function dig(optionList: OptionType[], isChildren = false) {
// for loop to speed up collection speed
for (let i = 0; i < optionList.length; i += 1) {
const option = optionList[i];
if (!option[fieldNames.value.options] || isChildren) {
valueOptions.set(option[fieldNames.value.value], option);
labelOptions.set(option[fieldNames.value.label], option);
newValueOptions.set(option[fieldNames.value.value], option);
newLabelOptions.set(option[fieldNames.value.label], option);
} else {
dig(option[fieldNames.value.options], true);
}
}
}
dig(mergedOptions);
return {
options: mergedOptions,
valueOptions,
labelOptions,
};
dig(newOptions);
mergedOptions.value = newOptions;
valueOptions.value = newValueOptions;
labelOptions.value = newLabelOptions;
});
return {
options: mergedOptions,
valueOptions,
labelOptions,
};
}

View File

@ -25,7 +25,7 @@ function convertNodeToOption<OptionType extends BaseOptionType = DefaultOptionTy
}
export function convertChildrenToData<OptionType extends BaseOptionType = DefaultOptionType>(
nodes: VueNode,
nodes: VueNode[],
optionOnly = false,
): OptionType[] {
const dd = flattenChildren(nodes as [])