diff --git a/components/auto-complete/OptGroup.tsx b/components/auto-complete/OptGroup.tsx index 2b44d826d..30a19de08 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/select/index.tsx b/components/select/index.tsx index 78c16460f..aebbf6f78 100644 --- a/components/select/index.tsx +++ b/components/select/index.tsx @@ -1,10 +1,10 @@ import type { App, PropType, Plugin, ExtractPropTypes } from 'vue'; import { computed, defineComponent, ref } from 'vue'; import classNames from '../_util/classNames'; -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 type { BaseSelectRef } from '../vc-select'; +import RcSelect, { selectProps as vcSelectProps, Option, OptGroup } from '../vc-select'; +import type { BaseOptionType, DefaultOptionType } from '../vc-select/Select'; +import type { OptionProps } from '../vc-select/Option'; import getIcons from './utils/iconUtil'; import PropTypes from '../_util/vue-types'; import useConfigInject from '../_util/hooks/useConfigInject'; diff --git a/components/vc-select2/BaseSelect.tsx b/components/vc-select/BaseSelect.tsx similarity index 100% rename from components/vc-select2/BaseSelect.tsx rename to components/vc-select/BaseSelect.tsx diff --git a/components/vc-select/OptGroup.tsx b/components/vc-select/OptGroup.tsx index 36c3ecc90..00a60e353 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 a8d68184b..6c98f3287 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 0f6130e0e..7cb1aa68d 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; - } + } else if (which === KeyCode.P) { + offset = -1; } - - if (offset !== 0) { - const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); - scrollIntoView(nextActiveIndex); - setActive(nextActiveIndex, true); - } - - break; } - // >>> Select - case KeyCode.ENTER: { - // value - const item = memoFlattenOptions.value[state.activeIndex]; - if (item && !item.data.disabled) { - onSelectValue(item.data.value); - } else { - onSelectValue(undefined); - } - - if (props.open) { - event.preventDefault(); - } - - break; + if (offset !== 0) { + const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); + scrollIntoView(nextActiveIndex); + setActive(nextActiveIndex, true); } - // >>> Close - case KeyCode.ESC: { - props.onToggleOpen(false); - if (props.open) { - event.stopPropagation(); - } + break; + } + + // >>> Select + case KeyCode.ENTER: { + // value + const item = memoFlattenOptions.value[state.activeIndex]; + if (item && !item.data.disabled) { + onSelectValue(item.data.value); + } else { + onSelectValue(undefined); + } + + if (baseProps.open) { + event.preventDefault(); + } + + break; + } + + // >>> Close + case KeyCode.ESC: { + baseProps.toggleOpen(false); + if (baseProps.open) { + event.stopPropagation(); } } - }, - onKeyup: () => {}, - - 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) { + const onKeyup = () => {}; + + const scrollTo = (index: number) => { + scrollIntoView(index); + }; + 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 ac1051831..796668dcc 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 ExportedSelectProps = SelectProps< - SelectOptionsType[number], - T ->; +export type OnActiveValue = ( + active: RawValueType, + index: number, + info?: { source?: 'keyboard' | 'mouse' }, +) => void; -export function selectProps() { - return selectBaseProps(); +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; } -const Select = defineComponent({ +export type DraftValueType = + | RawValueType + | LabelInValueType + | DisplayValueType + | (RawValueType | LabelInValueType | DisplayValueType)[]; + +export type FilterFunc = (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[]; +} + +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-select2/SelectContext.ts b/components/vc-select/SelectContext.ts similarity index 100% rename from components/vc-select2/SelectContext.ts rename to components/vc-select/SelectContext.ts diff --git a/components/vc-select/SelectTrigger.tsx b/components/vc-select/SelectTrigger.tsx index cd6dd6676..ed4ed7550 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(); - }, + 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`; - methods: { - getPopupElement() { - return this.popupRef.current; - }, - }, + let popupNode = popupElement; + if (dropdownRender) { + popupNode = dropdownRender({ menuNode: popupElement, props }); + } - 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`; + const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; - let popupNode = popupElement; - if (dropdownRender) { - popupNode = dropdownRender({ menuNode: popupElement, props }); - } + const popupStyle = { minWidth: `${containerWidth}px`, ...dropdownStyle }; - const builtInPlacements = getBuiltInPlacements(dropdownMatchSelectWidth); - - const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; - - 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 f3474ac0a..9013055cd 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, diff --git a/components/vc-select/Selector/MultipleSelector.tsx b/components/vc-select/Selector/MultipleSelector.tsx index d1aaa9c4f..4fddf5dfb 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,8 @@ 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'; type SelectorProps = InnerSelectorProps & { // Icon @@ -24,7 +18,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 +27,7 @@ type SelectorProps = InnerSelectorProps & { choiceTransitionName?: string; // Event - onSelect: (value: RawValueType, option: { selected: boolean }) => void; + onRemove: (value: DisplayValueType) => void; }; const props = { @@ -49,7 +43,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 +52,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, @@ -111,6 +105,7 @@ const SelectSelector = defineComponent({ // ===================== Render ====================== // >>> Render Selector Node. Includes Item & Rest function defaultRenderSelector( + title: VueNode, content: VueNode, itemDisabled: boolean, closable?: boolean, @@ -122,9 +117,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,17 +136,17 @@ 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); }; - return ( {props.tagRender({ @@ -162,12 +155,14 @@ const SelectSelector = defineComponent({ disabled: itemDisabled, closable, onClose, + option, })} ); } - 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 +178,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 +207,7 @@ const SelectSelector = defineComponent({ disabled, autofocus, autocomplete, - accessibilityIndex, + activeDescendantId, tabindex, onInputChange, onInputPaste, @@ -241,7 +234,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 0796f4cab..a6ecde102 100644 --- a/components/vc-select/Selector/SingleSelector.tsx +++ b/components/vc-select/Selector/SingleSelector.tsx @@ -9,7 +9,6 @@ import type { VueNode } from '../../_util/type'; 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, @@ -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, @@ -126,7 +136,7 @@ const SingleSelector = defineComponent({ autofocus={autofocus} autocomplete={autocomplete} editable={inputEditable.value} - accessibilityIndex={accessibilityIndex} + activeDescendantId={activeDescendantId} value={inputValue.value} onKeydown={onInputKeyDown} onMousedown={onInputMouseDown} @@ -150,9 +160,7 @@ const SingleSelector = defineComponent({ )} {/* Display placeholder */} - {!item && !hasTextInput.value && ( - {placeholder} - )} + {renderPlaceholder()} ); }; diff --git a/components/vc-select/Selector/index.tsx b/components/vc-select/Selector/index.tsx index 34b29ff12..6fc30878e 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,58 +232,44 @@ const Selector = defineComponent({ props.onToggleOpen(); } }; - - return { + expose({ focus: () => { inputRef.current.focus(); }, blur: () => { inputRef.current.blur(); }, - onMousedown, - onClick, - onInputPaste, - inputRef, - onInternalInputKeyDown, - onInternalInputMouseDown, - onInputChange, - onInputCompositionEnd, - onInputCompositionStart, + }); + + 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} +
+ ); }; }, - 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, - }; - const selectNode = multiple ? ( - - ) : ( - - ); - return ( -
- {selectNode} -
- ); - }, }); export default Selector; diff --git a/components/vc-select/Selector/interface.ts b/components/vc-select/Selector/interface.ts index 49915ad21..18dfd6709 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 a91b8e67d..6c95ce1d9 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 cbd4a1309..000000000 --- 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/generate.tsx b/components/vc-select/generate.tsx deleted file mode 100644 index e29ad9ab9..000000000 --- a/components/vc-select/generate.tsx +++ /dev/null @@ -1,1246 +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 - */ - -import KeyCode from '../_util/KeyCode'; -import classNames from '../_util/classNames'; -import Selector from './Selector'; -import SelectTrigger from './SelectTrigger'; -import type { Mode, RenderDOMFunc, OnActiveValue, FieldNames } from './interface'; -import type { - GetLabeledValue, - FilterOptions, - FilterFunc, - DefaultValueType, - RawValueType, - Key, - DisplayLabelValueType, - FlattenOptionsType, - SingleType, - OnClear, - SelectSource, - CustomTagProps, -} from './interface/generator'; -import { INTERNAL_PROPS_MARK } from './interface/generator'; -import type { OptionListProps } from './OptionList'; -import { toInnerValue, toOuterValues, removeLastEnabledValue, getUUID } from './utils/commonUtil'; -import TransBtn from './TransBtn'; -import useLock from './hooks/useLock'; -import useDelayReset from './hooks/useDelayReset'; -import { getSeparatedContent } from './utils/valueUtil'; -import useSelectTriggerControl from './hooks/useSelectTriggerControl'; -import useCacheDisplayValue from './hooks/useCacheDisplayValue'; -import useCacheOptions from './hooks/useCacheOptions'; -import type { CSSProperties, PropType, VNode } from 'vue'; -import { - getCurrentInstance, - computed, - defineComponent, - onBeforeUnmount, - onMounted, - provide, - ref, - shallowRef, - watch, - watchEffect, -} from 'vue'; -import createRef from '../_util/createRef'; -import PropTypes from '../_util/vue-types'; -import warning from '../_util/warning'; -import isMobile from '../vc-util/isMobile'; -import { getTextFromElement } from '../_util/props-util'; -import type { VueNode } from '../_util/type'; - -const DEFAULT_OMIT_PROPS = [ - 'children', - 'removeIcon', - 'placeholder', - 'autofocus', - 'maxTagCount', - 'maxTagTextLength', - 'maxTagPlaceholder', - 'choiceTransitionName', - 'onInputKeyDown', - 'tabindex', -]; - -export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight'; - -export function selectBaseProps() { - return { - prefixCls: String, - id: String, - - // Options - options: { type: Array as PropType }, - mode: { type: String as PropType }, - - // Value - value: { - type: [String, Number, Object, Array] as PropType, - default: undefined as ValueType, - }, - defaultValue: { - type: [String, Number, Object, Array] as PropType, - default: undefined as ValueType, - }, - labelInValue: { type: Boolean, default: undefined }, - - fieldNames: { type: Object as PropType }, - // Search - inputValue: String, - searchValue: String, - optionFilterProp: String, - /** - * 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: { - type: Function as PropType<(optionA: OptionType, optionB: OptionType) => number>, - }, - showSearch: { type: Boolean, default: undefined }, - autoClearSearchValue: { type: Boolean, default: undefined }, - onSearch: { type: Function as PropType<(value: string) => void> }, - onClear: { type: Function as PropType }, - - // Icons - allowClear: { type: Boolean, default: undefined }, - clearIcon: PropTypes.any, - showArrow: { type: Boolean, default: undefined }, - inputIcon: PropTypes.any, - removeIcon: PropTypes.any, - menuItemSelectedIcon: PropTypes.any, - - // Dropdown - open: { type: Boolean, default: undefined }, - defaultOpen: { type: Boolean, default: undefined }, - listHeight: Number, - listItemHeight: Number, - dropdownStyle: { type: Object as PropType }, - dropdownClassName: String, - dropdownMatchSelectWidth: { - type: [Boolean, Number] as PropType, - default: undefined, - }, - placement: { - type: String as PropType, - }, - virtual: { type: Boolean, default: undefined }, - dropdownRender: { type: Function as PropType<(menu: VNode) => any> }, - dropdownAlign: PropTypes.any, - animation: String, - transitionName: String, - getPopupContainer: { type: Function as PropType }, - direction: { type: String as PropType<'ltr' | 'rtl'> }, - - // Others - disabled: { type: Boolean, default: undefined }, - loading: { type: Boolean, default: undefined }, - autofocus: { type: Boolean, default: undefined }, - defaultActiveFirstOption: { type: Boolean, default: undefined }, - notFoundContent: PropTypes.any, - placeholder: PropTypes.any, - backfill: { type: Boolean, default: undefined }, - /** @private Internal usage. Do not use in your production. */ - getInputElement: { type: Function as PropType<() => any> }, - optionLabelProp: String, - maxTagTextLength: Number, - maxTagCount: { type: [String, Number] as PropType }, - maxTagPlaceholder: PropTypes.any, - tokenSeparators: { type: Array as PropType }, - tagRender: { type: Function as PropType<(props: CustomTagProps) => any> }, - showAction: { type: Array as PropType<('focus' | 'click')[]> }, - tabindex: { type: [Number, String] }, - - // Events - onKeyup: { type: Function as PropType<(e: KeyboardEvent) => void> }, - onKeydown: { type: Function as PropType<(e: KeyboardEvent) => void> }, - onPopupScroll: { type: Function as PropType<(e: UIEvent) => void> }, - onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> }, - onSelect: { - type: Function as PropType<(value: SingleType, option: OptionType) => void>, - }, - onDeselect: { - type: Function as PropType<(value: SingleType, option: OptionType) => void>, - }, - onInputKeyDown: { type: Function as PropType<(e: KeyboardEvent) => void> }, - onClick: { type: Function as PropType<(e: MouseEvent) => void> }, - onChange: { - type: Function as PropType<(value: ValueType, option: OptionType | OptionType[]) => void>, - }, - onBlur: { type: Function as PropType<(e: FocusEvent) => void> }, - onFocus: { type: Function as PropType<(e: FocusEvent) => void> }, - onMousedown: { type: Function as PropType<(e: MouseEvent) => void> }, - onMouseenter: { type: Function as PropType<(e: MouseEvent) => void> }, - onMouseleave: { type: Function as PropType<(e: MouseEvent) => void> }, - - // Motion - choiceTransitionName: String, - - // Internal props - /** - * Only used in current version for internal event process. - * Do not use in production environment. - */ - internalProps: { - type: Object as PropType<{ - mark?: string; - onClear?: OnClear; - skipTriggerChange?: boolean; - skipTriggerSelect?: boolean; - onRawSelect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; - onRawDeselect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; - }>, - default: undefined as { - mark?: string; - onClear?: OnClear; - skipTriggerChange?: boolean; - skipTriggerSelect?: boolean; - onRawSelect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; - onRawDeselect?: (value: RawValueType, option: OptionType, source: SelectSource) => void; - }, - }, - children: { type: Array as PropType }, - }; -} - -class Helper { - SelectBaseProps = selectBaseProps(); -} -type FuncReturnType = Helper['SelectBaseProps']; - -export type SelectProps = FuncReturnType; - -export interface GenerateConfig { - prefixCls: string; - components: { - // TODO - optionList: ( - props: Omit, 'options'> & { options?: OptionType[] }, - ) => JSX.Element; - // optionList: DefineComponent< - // Omit, 'options'> & { options?: OptionType[] } - // >; - }; - /** Convert jsx tree into `OptionType[]` */ - convertChildrenToData: (children: VueNode) => OptionType[]; - /** Flatten nest options into raw option list */ - flattenOptions: (options: OptionType[], props: any) => FlattenOptionsType; - /** Convert single raw value into { label, value } format. Will be called by each value */ - getLabeledValue: GetLabeledValue>; - filterOptions: FilterOptions; - findValueOption: // Need still support legacy ts api - | ((values: RawValueType[], options: FlattenOptionsType) => OptionType[]) - // New API add prevValueOptions support - | (( - values: RawValueType[], - options: FlattenOptionsType, - info?: { prevValueOptions?: OptionType[][]; props?: any }, - ) => OptionType[]); - /** Check if a value is disabled */ - isValueDisabled: (value: RawValueType, options: FlattenOptionsType) => boolean; - warningProps?: (props: any) => void; - fillOptionsWithMissingValue?: ( - options: OptionType[], - value: DefaultValueType, - optionLabelProp: string, - labelInValue: boolean, - ) => OptionType[]; - omitDOMProps?: (props: object) => object; -} - -type ValueType = DefaultValueType; -/** - * This function is in internal usage. - * Do not use it in your prod env since we may refactor this. - */ -export default function generateSelector< - OptionType extends { - value?: RawValueType; - label?: any; - key?: Key; - disabled?: boolean; - }, ->(config: GenerateConfig) { - const { - prefixCls: defaultPrefixCls, - components: { optionList: OptionList }, - convertChildrenToData, - flattenOptions, - getLabeledValue, - filterOptions, - isValueDisabled, - findValueOption, - warningProps, - fillOptionsWithMissingValue, - omitDOMProps, - } = config as any; - const Select = defineComponent({ - name: 'Select', - slots: ['option'], - inheritAttrs: false, - props: selectBaseProps(), - setup(props, { expose, attrs, slots }) { - const useInternalProps = computed( - () => props.internalProps && props.internalProps.mark === INTERNAL_PROPS_MARK, - ); - warning( - props.optionFilterProp !== 'children', - 'Select', - 'optionFilterProp not support children, please use label instead', - ); - const containerRef = ref(); - const triggerRef = ref(); - const selectorRef = ref(); - const listRef = ref(); - const tokenWithEnter = computed(() => - (props.tokenSeparators || []).some(tokenSeparator => - ['\n', '\r\n'].includes(tokenSeparator), - ), - ); - - /** Used for component focused management */ - const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset(); - - const mergedId = computed(() => props.id || `rc_select_${getUUID()}`); - - // optionLabelProp - const mergedOptionLabelProp = computed(() => { - let mergedOptionLabelProp = props.optionLabelProp; - if (mergedOptionLabelProp === undefined) { - mergedOptionLabelProp = props.options ? 'label' : 'children'; - } - return mergedOptionLabelProp; - }); - - // labelInValue - const mergedLabelInValue = computed(() => - props.mode === 'combobox' ? false : props.labelInValue, - ); - - const isMultiple = computed(() => props.mode === 'tags' || props.mode === 'multiple'); - - const mergedShowSearch = computed(() => - props.showSearch !== undefined - ? props.showSearch - : isMultiple.value || props.mode === 'combobox', - ); - - const mobile = ref(false); - onMounted(() => { - mobile.value = isMobile(); - }); - - // ============================== Ref =============================== - const selectorDomRef = createRef(); - - const innerSearchValue = ref(''); - const setInnerSearchValue = (val: string) => { - innerSearchValue.value = val; - }; - - const mergedValue = ref(props.value !== undefined ? props.value : props.defaultValue); - watch( - () => props.value, - () => { - mergedValue.value = props.value; - innerSearchValue.value = ''; - }, - ); - // ============================= Value ============================== - - /** Unique raw values */ - const mergedRawValueArr = computed(() => - toInnerValue(mergedValue.value, { - labelInValue: mergedLabelInValue.value, - combobox: props.mode === 'combobox', - }), - ); - const mergedRawValue = computed(() => mergedRawValueArr.value[0]); - const mergedValueMap = computed(() => mergedRawValueArr.value[1]); - /** We cache a set of raw values to speed up check */ - const rawValues = computed(() => new Set(mergedRawValue.value)); - - // ============================= Option ============================= - // Set by option list active, it will merge into search input when mode is `combobox` - const activeValue = ref(); - const setActiveValue = (val: string) => { - activeValue.value = val; - }; - - const mergedSearchValue = computed(() => { - let mergedSearchValue = innerSearchValue.value; - if (props.mode === 'combobox' && mergedValue.value !== undefined) { - mergedSearchValue = mergedValue.value as string; - } else if (props.searchValue !== undefined) { - mergedSearchValue = props.searchValue; - } else if (props.inputValue) { - mergedSearchValue = props.inputValue; - } - return mergedSearchValue; - }); - - const mergedOptions = computed((): OptionType[] => { - let newOptions = props.options; - if (newOptions === undefined) { - newOptions = convertChildrenToData(props.children as VueNode); - } - - /** - * `tags` should fill un-list item. - * This is not cool here since TreeSelect do not need this - */ - if (props.mode === 'tags' && fillOptionsWithMissingValue) { - newOptions = fillOptionsWithMissingValue( - newOptions, - mergedValue.value, - mergedOptionLabelProp.value, - props.labelInValue, - ); - } - - return newOptions || ([] as OptionType[]); - }); - - const mergedFlattenOptions = computed(() => flattenOptions(mergedOptions.value, props)); - - const getValueOption = useCacheOptions(mergedFlattenOptions); - - // Display options for OptionList - const displayOptions = computed(() => { - if (!mergedSearchValue.value || !mergedShowSearch.value) { - return [...mergedOptions.value] as OptionType[]; - } - const { optionFilterProp = 'value', mode, filterOption } = props; - const filteredOptions: OptionType[] = filterOptions( - mergedSearchValue.value, - mergedOptions.value, - { - optionFilterProp, - filterOption: - mode === 'combobox' && filterOption === undefined ? () => true : filterOption, - }, - ); - if ( - mode === 'tags' && - filteredOptions.every(opt => opt[optionFilterProp] !== mergedSearchValue.value) - ) { - filteredOptions.unshift({ - value: mergedSearchValue.value, - label: mergedSearchValue.value, - key: '__RC_SELECT_TAG_PLACEHOLDER__', - } as OptionType); - } - if (props.filterSort && Array.isArray(filteredOptions)) { - return ([...filteredOptions] as OptionType[]).sort(props.filterSort); - } - - return filteredOptions; - }); - - const displayFlattenOptions = computed(() => flattenOptions(displayOptions.value, props)); - onMounted(() => { - watch( - mergedSearchValue, - () => { - if (listRef.value && listRef.value.scrollTo) { - listRef.value.scrollTo(0); - } - }, - { flush: 'post', immediate: true }, - ); - }); - - // ============================ Selector ============================ - let displayValues = computed(() => { - const tmpValues = mergedRawValue.value.map((val: RawValueType) => { - const valueOptions = getValueOption([val]); - const displayValue = getLabeledValue(val, { - options: valueOptions, - prevValueMap: mergedValueMap.value, - labelInValue: mergedLabelInValue.value, - optionLabelProp: mergedOptionLabelProp.value, - }); - return { - ...displayValue, - disabled: isValueDisabled(val, valueOptions), - option: valueOptions[0], - }; - }); - - if ( - !props.mode && - tmpValues.length === 1 && - tmpValues[0].value === null && - tmpValues[0].label === null - ) { - return []; - } - - return tmpValues; - }); - - // Polyfill with cache label - displayValues = useCacheDisplayValue(displayValues); - - const triggerSelect = (newValue: RawValueType, isSelect: boolean, source: SelectSource) => { - const newValueOption = getValueOption([newValue]); - const outOption = findValueOption([newValue], newValueOption, { props })[0]; - const { internalProps = {} } = props; - if (!internalProps.skipTriggerSelect) { - // Skip trigger `onSelect` or `onDeselect` if configured - const selectValue = ( - mergedLabelInValue.value - ? getLabeledValue(newValue, { - options: newValueOption, - prevValueMap: mergedValueMap.value, - labelInValue: mergedLabelInValue.value, - optionLabelProp: mergedOptionLabelProp.value, - }) - : newValue - ) as SingleType; - - if (isSelect && props.onSelect) { - props.onSelect(selectValue, outOption); - } else if (!isSelect && props.onDeselect) { - props.onDeselect(selectValue, outOption); - } - } - - // Trigger internal event - if (useInternalProps.value) { - if (isSelect && internalProps.onRawSelect) { - internalProps.onRawSelect(newValue, outOption, source); - } else if (!isSelect && internalProps.onRawDeselect) { - internalProps.onRawDeselect(newValue, outOption, source); - } - } - }; - - // We need cache options here in case user update the option list - const prevValueOptions = shallowRef([]); - const setPrevValueOptions = (val: any[]) => { - prevValueOptions.value = val; - }; - const triggerChange = (newRawValues: RawValueType[]) => { - if ( - useInternalProps.value && - props.internalProps && - props.internalProps.skipTriggerChange - ) { - return; - } - const newRawValuesOptions = getValueOption(newRawValues); - const outValues = toOuterValues>(Array.from(newRawValues), { - labelInValue: mergedLabelInValue.value, - options: newRawValuesOptions as any, - getLabeledValue, - prevValueMap: mergedValueMap.value, - optionLabelProp: mergedOptionLabelProp.value, - }); - - const outValue: ValueType = (isMultiple.value ? outValues : outValues[0]) as ValueType; - // Skip trigger if prev & current value is both empty - if ( - props.onChange && - (mergedRawValue.value.length !== 0 || (outValues as []).length !== 0) - ) { - const outOptions = findValueOption(newRawValues, newRawValuesOptions, { - prevValueOptions: prevValueOptions.value, - props, - }); - - // We will cache option in case it removed by ajax - setPrevValueOptions( - outOptions.map((option, index) => { - const clone = { ...option }; - Object.defineProperty(clone, '_INTERNAL_OPTION_VALUE_', { - get: () => newRawValues[index], - }); - return clone; - }), - ); - - props.onChange(outValue, isMultiple.value ? outOptions : outOptions[0]); - } - - if (props.value === undefined) { - mergedValue.value = outValue; - } - }; - - const onInternalSelect = ( - newValue: RawValueType, - { selected, source }: { selected: boolean; source: 'option' | 'selection' }, - ) => { - const { autoClearSearchValue = true } = props; - if (props.disabled) { - return; - } - - let newRawValue: Set; - - if (isMultiple.value) { - newRawValue = new Set(mergedRawValue.value); - if (selected) { - newRawValue.add(newValue); - } else { - newRawValue.delete(newValue); - } - } else { - newRawValue = new Set(); - newRawValue.add(newValue); - } - - // Multiple always trigger change and single should change if value changed - if ( - isMultiple.value || - (!isMultiple.value && Array.from(mergedRawValue.value)[0] !== newValue) - ) { - triggerChange(Array.from(newRawValue)); - } - - // Trigger `onSelect`. Single mode always trigger select - triggerSelect(newValue, !isMultiple.value || selected, source); - - // Clean search value if single or configured - if (props.mode === 'combobox') { - setInnerSearchValue(String(newValue)); - setActiveValue(''); - } else if (!isMultiple.value || autoClearSearchValue) { - setInnerSearchValue(''); - setActiveValue(''); - } - }; - - const onInternalOptionSelect = (newValue: RawValueType, info: { selected: boolean }) => { - onInternalSelect(newValue, { ...info, source: 'option' }); - }; - - const onInternalSelectionSelect = (newValue: RawValueType, info: { selected: boolean }) => { - onInternalSelect(newValue, { ...info, source: 'selection' }); - }; - - // ============================== 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 && !displayOptions.value.length, - ); - - 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); - } - } - }; - - useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen); - - // ============================= Search ============================= - const triggerSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => { - let ret = true; - let newSearchText = searchText; - const preSearchValue = mergedSearchValue.value; - setActiveValue(null); - - // Check if match the `tokenSeparators` - const patchLabels: string[] = isCompositing - ? null - : getSeparatedContent(searchText, props.tokenSeparators as string[]); - let patchRawValues: RawValueType[] = patchLabels; - - if (props.mode === 'combobox') { - // Only typing will trigger onChange - if (fromTyping) { - triggerChange([newSearchText]); - } - } else if (patchLabels) { - newSearchText = ''; - - if (props.mode !== 'tags') { - patchRawValues = patchLabels - .map(label => { - const item = mergedFlattenOptions.value.find( - ({ data }) => getTextFromElement(data[mergedOptionLabelProp.value]) === label, - ); - return item ? item.data.value : null; - }) - .filter((val: RawValueType) => val !== null); - } - - const newRawValues = Array.from( - new Set([...mergedRawValue.value, ...patchRawValues]), - ); - triggerChange(newRawValues); - newRawValues.forEach(newRawValue => { - triggerSelect(newRawValue, true, 'input'); - }); - - // Should close when paste finish - onToggleOpen(false); - - // Tell Selector that break next actions - ret = false; - } - - setInnerSearchValue(newSearchText); - - if (props.onSearch && preSearchValue !== newSearchText) { - props.onSearch(newSearchText); - } - - 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 onSearchSubmit = (searchText: string) => { - // prevent empty tags from appearing when you click the Enter button - if (!searchText || !searchText.trim()) { - return; - } - const newRawValues = Array.from( - new Set([...mergedRawValue.value, searchText]), - ); - triggerChange(newRawValues); - newRawValues.forEach(newRawValue => { - triggerSelect(newRawValue, true, 'input'); - }); - setInnerSearchValue(''); - }; - - // Close dropdown when disabled change - - watch( - () => props.disabled, - () => { - if (innerOpen.value && !!props.disabled) { - setInnerOpen(false); - } - }, - { immediate: true }, - ); - - // Close will clean up single mode search text - watch( - mergedOpen, - () => { - if (!mergedOpen.value && !isMultiple.value && props.mode !== 'combobox') { - triggerSearch('', false, 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 = (event: KeyboardEvent) => { - 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 && - isMultiple.value && - !mergedSearchValue.value && - mergedRawValue.value.length - ) { - const removeInfo = removeLastEnabledValue(displayValues.value, mergedRawValue.value); - - if (removeInfo.removedValue !== null) { - triggerChange(removeInfo.values); - triggerSelect(removeInfo.removedValue, false, 'input'); - } - } - - if (mergedOpen.value && listRef.value) { - listRef.value.onKeydown(event); - } - - if (props.onKeydown) { - props.onKeydown(event); - } - }; - - // KeyUp - const onInternalKeyUp = (event: KeyboardEvent) => { - if (mergedOpen.value && listRef.value) { - listRef.value.onKeyup(event); - } - - if (props.onKeyup) { - props.onKeyup(event); - } - }; - - // ========================== Focus / Blur ========================== - /** Record real focus status */ - const focusRef = ref(false); - - const onContainerFocus = (...args: any[]) => { - setMockFocused(true); - - if (!props.disabled) { - if (props.onFocus && !focusRef.value) { - props.onFocus(args[0]); - } - - // `showAction` should handle `focus` if set - if (props.showAction && props.showAction.includes('focus')) { - onToggleOpen(true); - } - } - - focusRef.value = true; - }; - - const onContainerBlur = (...args: any[]) => { - 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') { - triggerSearch('', false, false); - triggerChange(Array.from(new Set([...mergedRawValue.value, searchVal]))); - } else if (props.mode === 'multiple') { - // `multiple` mode only clean the search value but not trigger event - setInnerSearchValue(''); - } - } - - if (props.onBlur) { - props.onBlur(args[0]); - } - }; - provide('VCSelectContainerEvent', { - focus: onContainerFocus, - blur: onContainerBlur, - }); - const activeTimeoutIds: number[] = []; - - onMounted(() => { - activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); - activeTimeoutIds.splice(0, activeTimeoutIds.length); - }); - onBeforeUnmount(() => { - activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId)); - activeTimeoutIds.splice(0, activeTimeoutIds.length); - }); - - const onInternalMouseDown = (event: MouseEvent) => { - const { target } = event; - const popupElement: HTMLDivElement = triggerRef.value && 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 = window.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); - } - - if (props.onMousedown) { - props.onMousedown(event); - } - }; - - // ========================= Accessibility ========================== - const accessibilityIndex = ref(0); - const mergedDefaultActiveFirstOption = computed(() => - props.defaultActiveFirstOption !== undefined - ? props.defaultActiveFirstOption - : props.mode !== 'combobox', - ); - - const onActiveValue: OnActiveValue = (active, index, { source = 'keyboard' } = {}) => { - accessibilityIndex.value = index; - - if ( - props.backfill && - props.mode === 'combobox' && - active !== null && - source === 'keyboard' - ) { - setActiveValue(String(active)); - } - }; - - // ============================= Popup ============================== - const containerWidth = ref(null); - onMounted(() => { - watch( - triggerOpen, - () => { - if (triggerOpen.value) { - const newWidth = Math.ceil(containerRef.value.offsetWidth); - if (containerWidth.value !== newWidth) { - containerWidth.value = newWidth; - } - } - }, - { immediate: true }, - ); - }); - - const focus = () => { - selectorRef.value.focus(); - }; - const blur = () => { - selectorRef.value.blur(); - }; - expose({ - focus, - blur, - scrollTo: (...args: any[]) => listRef.value?.scrollTo(...args), - }); - const instance = getCurrentInstance(); - const onPopupMouseEnter = () => { - // We need force update here since popup dom is render async - instance.update(); - }; - return () => { - const { - prefixCls = defaultPrefixCls, - id, - - open, - defaultOpen, - options, - children, - - mode, - value, - defaultValue, - labelInValue, - - // Search related - showSearch, - inputValue, - searchValue, - filterOption, - optionFilterProp, - autoClearSearchValue, - onSearch, - - // Icons - allowClear, - clearIcon, - showArrow, - inputIcon, - menuItemSelectedIcon, - - // Others - disabled, - loading, - defaultActiveFirstOption, - notFoundContent = 'Not Found', - optionLabelProp, - backfill, - getInputElement, - getPopupContainer, - placement, - - // Dropdown - listHeight = 200, - listItemHeight = 20, - animation, - transitionName, - virtual, - dropdownStyle, - dropdownClassName, - dropdownMatchSelectWidth, - dropdownRender, - dropdownAlign, - showAction, - direction, - fieldNames, - - // Tags - tokenSeparators, - tagRender, - - // Events - onPopupScroll, - onDropdownVisibleChange, - onFocus, - onBlur, - onKeyup, - onKeydown, - onMousedown, - - onChange, - onSelect, - onDeselect, - onClear, - - internalProps = {}, - - ...restProps - } = { ...props, ...attrs }; //as SelectProps; - // ============================= Input ============================== - // Only works in `combobox` - const customizeInputElement: VueNode = - (mode === 'combobox' && getInputElement && getInputElement()) || null; - - const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps; - DEFAULT_OMIT_PROPS.forEach(prop => { - delete domProps[prop]; - }); - const popupNode = ( - - ); - - // ============================= Clear ============================== - let clearNode: VNode | JSX.Element; - const onClearMouseDown = () => { - // Trigger internal `onClear` event - if (useInternalProps.value && internalProps.onClear) { - internalProps.onClear(); - } - - if (onClear) { - onClear(); - } - - triggerChange([]); - triggerSearch('', false, false); - }; - - if (!disabled && allowClear && (mergedRawValue.value.length || mergedSearchValue.value)) { - clearNode = ( - - × - - ); - } - - // ============================= Arrow ============================== - const mergedShowArrow = - showArrow !== undefined - ? showArrow - : loading || (!isMultiple.value && mode !== 'combobox'); - let arrowNode: VNode | JSX.Element; - - if (mergedShowArrow) { - arrowNode = ( - - ); - } - - // ============================ Warning ============================= - if (process.env.NODE_ENV !== 'production' && warningProps) { - warningProps(props); - } - - // ============================= Render ============================= - const mergedClassName = classNames(prefixCls, attrs.class, { - [`${prefixCls}-focused`]: mockFocused.value, - [`${prefixCls}-multiple`]: isMultiple.value, - [`${prefixCls}-single`]: !isMultiple.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, - }); - return ( -
- {mockFocused.value && !mergedOpen.value && ( - - {/* Merge into one string to make screen reader work as expect */} - {`${mergedRawValue.value.join(', ')}`} - - )} - selectorDomRef.current} - > - - - - {arrowNode} - {clearNode} -
- ); - }; - }, - }); - return Select; -} diff --git a/components/vc-select2/hooks/useBaseProps.ts b/components/vc-select/hooks/useBaseProps.ts similarity index 100% rename from components/vc-select2/hooks/useBaseProps.ts rename to components/vc-select/hooks/useBaseProps.ts diff --git a/components/vc-select2/hooks/useCache.ts b/components/vc-select/hooks/useCache.ts similarity index 100% rename from components/vc-select2/hooks/useCache.ts rename to components/vc-select/hooks/useCache.ts diff --git a/components/vc-select/hooks/useCacheDisplayValue.ts b/components/vc-select/hooks/useCacheDisplayValue.ts deleted file mode 100644 index 391a810a5..000000000 --- a/components/vc-select/hooks/useCacheDisplayValue.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ComputedRef, Ref } from 'vue'; -import { computed } from 'vue'; -import type { DisplayLabelValueType } from '../interface/generator'; - -export default function useCacheDisplayValue( - values: Ref, -): ComputedRef { - let prevValues = [...values.value]; - - const mergedValues = computed(() => { - // Create value - label map - const valueLabels = new Map(); - prevValues.forEach(({ value, label }) => { - if (value !== label) { - valueLabels.set(value, label); - } - }); - - const resultValues = values.value.map(item => { - const cacheLabel = valueLabels.get(item.value); - if (item.isCacheable && cacheLabel) { - return { - ...item, - label: cacheLabel, - }; - } - - return item; - }); - - prevValues = resultValues; - return resultValues; - }); - - return mergedValues; -} diff --git a/components/vc-select/hooks/useCacheOptions.ts b/components/vc-select/hooks/useCacheOptions.ts deleted file mode 100644 index 1daf8908a..000000000 --- a/components/vc-select/hooks/useCacheOptions.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { Ref } from 'vue'; -import { computed } from 'vue'; -import type { RawValueType, FlattenOptionsType, Key } from '../interface/generator'; - -export default function useCacheOptions< - OptionType extends { - value?: RawValueType; - label?: any; - key?: Key; - disabled?: boolean; - }, ->(options: Ref) { - const optionMap = computed(() => { - const map: Map[number]> = new Map(); - options.value.forEach(item => { - const { value } = item; - map.set(value, item); - }); - return map; - }); - - const getValueOption = (valueList: RawValueType[]) => - valueList.map(value => optionMap.value.get(value)).filter(Boolean); - - return getValueOption; -} diff --git a/components/vc-select2/hooks/useFilterOptions.ts b/components/vc-select/hooks/useFilterOptions.ts similarity index 100% rename from components/vc-select2/hooks/useFilterOptions.ts rename to components/vc-select/hooks/useFilterOptions.ts diff --git a/components/vc-select2/hooks/useId.ts b/components/vc-select/hooks/useId.ts similarity index 100% rename from components/vc-select2/hooks/useId.ts rename to components/vc-select/hooks/useId.ts diff --git a/components/vc-select2/hooks/useOptions.ts b/components/vc-select/hooks/useOptions.ts similarity index 100% rename from components/vc-select2/hooks/useOptions.ts rename to components/vc-select/hooks/useOptions.ts diff --git a/components/vc-select/index.ts b/components/vc-select/index.ts index 3d2f1ecce..685603662 100644 --- a/components/vc-select/index.ts +++ b/components/vc-select/index.ts @@ -1,11 +1,12 @@ -import type { ExportedSelectProps } from './Select'; +import type { SelectProps } from './Select'; import Select, { selectProps } from './Select'; import Option from './Option'; import OptGroup from './OptGroup'; -import { selectBaseProps } from './generate'; -import type { ExtractPropTypes } from 'vue'; +import BaseSelect from './BaseSelect'; +import type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate } from './BaseSelect'; +import useBaseProps from './hooks/useBaseProps'; -export type SelectProps = Partial>>; -export { Option, OptGroup, selectBaseProps, selectProps }; +export { Option, OptGroup, selectProps, BaseSelect, useBaseProps }; +export type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate, SelectProps }; export default Select; diff --git a/components/vc-select2/interface.ts b/components/vc-select/interface.ts similarity index 100% rename from components/vc-select2/interface.ts rename to components/vc-select/interface.ts diff --git a/components/vc-select/interface/generator.ts b/components/vc-select/interface/generator.ts deleted file mode 100644 index aa3c6702b..000000000 --- a/components/vc-select/interface/generator.ts +++ /dev/null @@ -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 { - 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 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 = ( - value: RawValueType, - config: { - options: FOT; - prevValueMap: Map; - labelInValue: boolean; - optionLabelProp: string; - }, -) => LabelValueType; - -export type FilterOptions = ( - searchValue: string, - options: OptionsType, - /** Component props, since Select & TreeSelect use different prop name, use any here */ - config: { - optionFilterProp: string; - filterOption: boolean | FilterFunc; - }, -) => OptionsType; - -export type FilterFunc = (inputValue: string, option?: OptionType) => boolean; - -export type FlattenOptionsType = { - 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; -}; - -export type DropdownRender = (opt?: DropdownObject) => VueNode; diff --git a/components/vc-select/interface/index.ts b/components/vc-select/interface/index.ts deleted file mode 100644 index c71cee0a3..000000000 --- a/components/vc-select/interface/index.ts +++ /dev/null @@ -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; -} diff --git a/components/vc-select/utils/commonUtil.ts b/components/vc-select/utils/commonUtil.ts index 4a4e41070..6e61f053d 100644 --- a/components/vc-select/utils/commonUtil.ts +++ b/components/vc-select/utils/commonUtil.ts @@ -1,11 +1,3 @@ -import type { - RawValueType, - GetLabeledValue, - LabelValueType, - DefaultValueType, - FlattenOptionsType, -} from '../interface/generator'; - export function toArray(value: T | T[]): T[] { if (Array.isArray(value)) { return value; @@ -13,116 +5,8 @@ export function toArray(value: T | T[]): T[] { return value !== undefined ? [value] : []; } -/** - * Convert outer props value into internal value - */ -export function toInnerValue( - value: DefaultValueType, - { labelInValue, combobox }: { labelInValue: boolean; combobox: boolean }, -): [RawValueType[], Map] { - const valueMap = new Map(); - if (value === undefined || (value === '' && combobox)) { - return [[], valueMap]; - } - - const values = Array.isArray(value) ? value : [value]; - - let rawValues = values as RawValueType[]; - - if (labelInValue) { - rawValues = (values as LabelValueType[]) - .filter(item => item !== null) - .map((itemValue: LabelValueType) => { - const { key, value: val } = itemValue; - const finalVal = val !== undefined ? val : key; - valueMap.set(finalVal, itemValue); - return finalVal; - }); - } - - return [rawValues, valueMap]; -} - -/** - * Convert internal value into out event value - */ -export function toOuterValues( - valueList: RawValueType[], - { - optionLabelProp, - labelInValue, - prevValueMap, - options, - getLabeledValue, - }: { - optionLabelProp: string; - labelInValue: boolean; - getLabeledValue: GetLabeledValue; - options: FOT; - prevValueMap: Map; - }, -): RawValueType[] | LabelValueType[] | DefaultValueType { - let values: DefaultValueType = valueList; - - if (labelInValue) { - values = values.map(val => - getLabeledValue(val, { - options, - prevValueMap, - labelInValue, - optionLabelProp, - }), - ); - } - - return values; -} - -export function removeLastEnabledValue< - T extends { disabled?: boolean }, - P extends RawValueType | object, ->(measureValues: T[], values: P[]): { values: P[]; removedValue: P } { - const newValues = [...values]; - - let removeIndex: number; - for (removeIndex = measureValues.length - 1; removeIndex >= 0; removeIndex -= 1) { - if (!measureValues[removeIndex].disabled) { - break; - } - } - - let removedValue = null; - - if (removeIndex !== -1) { - removedValue = newValues[removeIndex]; - newValues.splice(removeIndex, 1); - } - - return { - values: newValues, - removedValue, - }; -} - export const isClient = typeof window !== 'undefined' && window.document && window.document.documentElement; /** Is client side and not jsdom */ export const isBrowserClient = process.env.NODE_ENV !== 'test' && isClient; - -let uuid = 0; -/** Get unique id for accessibility usage */ -export function getUUID(): number | string { - let retId: string | number; - - // Test never reach - /* istanbul ignore if */ - if (isBrowserClient) { - retId = uuid; - uuid += 1; - } else { - retId = 'TEST_OR_SSR'; - } - - return retId; -} diff --git a/components/vc-select2/utils/keyUtil.ts b/components/vc-select/utils/keyUtil.ts similarity index 100% rename from components/vc-select2/utils/keyUtil.ts rename to components/vc-select/utils/keyUtil.ts diff --git a/components/vc-select/utils/legacyUtil.ts b/components/vc-select/utils/legacyUtil.ts index 14c1bc2f7..f7a58d214 100644 --- a/components/vc-select/utils/legacyUtil.ts +++ b/components/vc-select/utils/legacyUtil.ts @@ -1,9 +1,11 @@ import { flattenChildren, isValidElement } from '../../_util/props-util'; import type { VNode } from 'vue'; -import type { OptionData, OptionGroupData, OptionsType } from '../interface'; +import type { BaseOptionType, DefaultOptionType } from '../Select'; import type { VueNode } from '../../_util/type'; -function convertNodeToOption(node: VNode): OptionData { +function convertNodeToOption( + node: VNode, +): OptionType { const { key, children, @@ -18,13 +20,16 @@ function convertNodeToOption(node: VNode): OptionData { value: value !== undefined ? value : key, children: child, disabled: disabled || disabled === '', // support - ...(restProps as Omit), + ...(restProps as any), }; } -export function convertChildrenToData(nodes: VueNode, optionOnly = false): OptionsType { +export function convertChildrenToData( + nodes: VueNode[], + optionOnly = false, +): OptionType[] { const dd = flattenChildren(nodes as []) - .map((node: VNode, index: number): OptionData | OptionGroupData | null => { + .map((node: VNode, index: number): OptionType | null => { if (!isValidElement(node) || !node.type) { return null; } diff --git a/components/vc-select/utils/valueUtil.ts b/components/vc-select/utils/valueUtil.ts index 725a788be..ca21cff9e 100644 --- a/components/vc-select/utils/valueUtil.ts +++ b/components/vc-select/utils/valueUtil.ts @@ -1,24 +1,8 @@ +import type { BaseOptionType, DefaultOptionType, RawValueType, FieldNames } from '../Select'; import { warning } from '../../vc-util/warning'; -import { cloneVNode, isVNode } from 'vue'; -import type { - OptionsType as SelectOptionsType, - OptionData, - OptionGroupData, - FlattenOptionData, - FieldNames, -} from '../interface'; -import type { - LabelValueType, - FilterFunc, - RawValueType, - GetLabeledValue, - DefaultValueType, -} from '../interface/generator'; +import type { FlattenOptionData } from '../interface'; -import { toArray } from './commonUtil'; -import type { VueNode } from '../../_util/type'; - -function getKey(data: OptionData | OptionGroupData, index: number) { +function getKey(data: BaseOptionType, index: number) { const { key } = data; let value: RawValueType; @@ -35,11 +19,11 @@ function getKey(data: OptionData | OptionGroupData, index: number) { return `rc-index-key-${index}`; } -export function fillFieldNames(fieldNames?: FieldNames) { +export function fillFieldNames(fieldNames: FieldNames | undefined, childrenAsData: boolean) { const { label, value, options } = fieldNames || {}; return { - label: label || 'label', + label: label || (childrenAsData ? 'children' : 'label'), value: value || 'value', options: options || 'options', }; @@ -50,38 +34,43 @@ export function fillFieldNames(fieldNames?: FieldNames) { * We use `optionOnly` here is aim to avoid user use nested option group. * Here is simply set `key` to the index if not provided. */ -export function flattenOptions( - options: SelectOptionsType, - { fieldNames }: { fieldNames?: FieldNames } = {}, -): FlattenOptionData[] { - const flattenList: FlattenOptionData[] = []; +export function flattenOptions( + options: OptionType[], + { fieldNames, childrenAsData }: { fieldNames?: FieldNames; childrenAsData?: boolean } = {}, +): FlattenOptionData[] { + const flattenList: FlattenOptionData[] = []; const { label: fieldLabel, value: fieldValue, options: fieldOptions, - } = fillFieldNames(fieldNames); + } = fillFieldNames(fieldNames, false); - function dig(list: SelectOptionsType, isGroupOption: boolean) { + function dig(list: OptionType[], isGroupOption: boolean) { list.forEach(data => { const label = data[fieldLabel]; if (isGroupOption || !(fieldOptions in data)) { + const value = data[fieldValue]; // Option flattenList.push({ key: getKey(data, flattenList.length), groupOption: isGroupOption, data, label, - value: data[fieldValue], + value, }); } else { + let grpLabel = label; + if (grpLabel === undefined && childrenAsData) { + grpLabel = data.label; + } // Option Group flattenList.push({ key: getKey(data, flattenList.length), group: true, data, - label, + label: grpLabel, }); dig(data[fieldOptions], true); @@ -97,7 +86,7 @@ export function flattenOptions( /** * Inject `props` into `option` for legacy usage */ -function injectPropsWithOption(option: T): T { +export function injectPropsWithOption(option: T): T { const newOption = { ...option }; if (!('props' in newOption)) { Object.defineProperty(newOption, 'props', { @@ -114,154 +103,6 @@ function injectPropsWithOption(option: T): T { return newOption; } -export function findValueOption( - values: RawValueType[], - options: FlattenOptionData[], - { prevValueOptions = [] }: { prevValueOptions?: OptionData[] } = {}, -): OptionData[] { - const optionMap: Map = new Map(); - - options.forEach(({ data, group, value }) => { - if (!group) { - // Check if match - optionMap.set(value, data as OptionData); - } - }); - - return values.map(val => { - let option = optionMap.get(val); - - // Fallback to try to find prev options - if (!option) { - option = { - // eslint-disable-next-line no-underscore-dangle - ...prevValueOptions.find(opt => opt._INTERNAL_OPTION_VALUE_ === val), - }; - } - - return injectPropsWithOption(option); - }); -} - -export const getLabeledValue: GetLabeledValue = ( - value, - { options, prevValueMap, labelInValue, optionLabelProp }, -) => { - const item = findValueOption([value], options)[0]; - const result: LabelValueType = { - value, - }; - - const prevValItem: LabelValueType = labelInValue ? prevValueMap.get(value) : undefined; - - if (prevValItem && typeof prevValItem === 'object' && 'label' in prevValItem) { - result.label = prevValItem.label; - - if ( - item && - typeof prevValItem.label === 'string' && - typeof item[optionLabelProp] === 'string' && - prevValItem.label.trim() !== item[optionLabelProp].trim() - ) { - warning(false, '`label` of `value` is not same as `label` in Select options.'); - } - } else if (item && optionLabelProp in item) { - if (Array.isArray(item[optionLabelProp])) { - result.label = isVNode(item[optionLabelProp][0]) - ? cloneVNode(item[optionLabelProp][0]) - : item[optionLabelProp]; - } else { - result.label = item[optionLabelProp]; - } - } else { - result.label = value; - result.isCacheable = true; - } - - // Used for motion control - result.key = result.value; - - return result; -}; - -function toRawString(content: VueNode): string { - return toArray(content) - .map(item => { - if (isVNode(item)) { - return item?.el?.innerText || item?.el?.wholeText; - } else { - return item; - } - }) - .join(''); -} - -/** Filter single option if match the search text */ -function getFilterFunction(optionFilterProp: string) { - return (searchValue: string, option: OptionData | OptionGroupData) => { - const lowerSearchText = searchValue.toLowerCase(); - - // Group label search - if ('options' in option) { - return toRawString(option.label).toLowerCase().includes(lowerSearchText); - } - // Option value search - const rawValue = option[optionFilterProp]; - const value = toRawString(rawValue).toLowerCase(); - return value.includes(lowerSearchText); - }; -} - -/** Filter options and return a new options by the search text */ -export function filterOptions( - searchValue: string, - options: SelectOptionsType, - { - optionFilterProp, - filterOption, - }: { optionFilterProp: string; filterOption: boolean | FilterFunc }, -) { - const filteredOptions: SelectOptionsType = []; - let filterFunc: FilterFunc; - - if (filterOption === false) { - return [...options]; - } - if (typeof filterOption === 'function') { - filterFunc = filterOption; - } else { - filterFunc = getFilterFunction(optionFilterProp); - } - - options.forEach(item => { - // Group should check child options - if ('options' in item) { - // Check group first - const matchGroup = filterFunc(searchValue, item); - if (matchGroup) { - filteredOptions.push(item); - } else { - // Check option - const subOptions = item.options.filter(subItem => filterFunc(searchValue, subItem)); - if (subOptions.length) { - filteredOptions.push({ - ...item, - options: subOptions, - }); - } - } - - return; - } - - if (filterFunc(searchValue, injectPropsWithOption(item))) { - filteredOptions.push(item); - } - }); - - return filteredOptions; -} - export function getSeparatedContent(text: string, tokens: string[]): string[] { if (!tokens || !tokens.length) { return null; @@ -285,53 +126,3 @@ export function getSeparatedContent(text: string, tokens: string[]): string[] { const list = separate(text, tokens); return match ? list : null; } - -export function isValueDisabled(value: RawValueType, options: FlattenOptionData[]): boolean { - const option = findValueOption([value], options)[0]; - return option.disabled; -} - -/** - * `tags` mode should fill un-list item into the option list - */ -export function fillOptionsWithMissingValue( - options: SelectOptionsType, - value: DefaultValueType, - optionLabelProp: string, - labelInValue: boolean, -): SelectOptionsType { - const values = toArray(value).slice().sort(); - const cloneOptions = [...options]; - - // Convert options value to set - const optionValues = new Set(); - options.forEach(opt => { - if (opt.options) { - opt.options.forEach((subOpt: OptionData) => { - optionValues.add(subOpt.value); - }); - } else { - optionValues.add((opt as OptionData).value); - } - }); - - // Fill missing value - values.forEach(item => { - const val: RawValueType = labelInValue - ? (item as LabelValueType).value - : (item as RawValueType); - - if (!optionValues.has(val)) { - cloneOptions.push( - labelInValue - ? { - [optionLabelProp]: (item as LabelValueType).label, - value: val, - } - : { value: val }, - ); - } - }); - - return cloneOptions; -} diff --git a/components/vc-select/utils/warningPropsUtil.ts b/components/vc-select/utils/warningPropsUtil.ts index b08b6afd5..aa7ce292d 100644 --- a/components/vc-select/utils/warningPropsUtil.ts +++ b/components/vc-select/utils/warningPropsUtil.ts @@ -1,10 +1,10 @@ import warning, { noteOnce } from '../../vc-util/warning'; -import type { SelectProps } from '..'; import { convertChildrenToData } from './legacyUtil'; import { toArray } from './commonUtil'; -import type { RawValueType, LabelValueType } from '../interface/generator'; import { isValidElement } from '../../_util/props-util'; import type { VNode } from 'vue'; +import type { RawValueType, LabelInValueType, SelectProps } from '../Select'; +import { isMultiple } from '../BaseSelect'; function warningProps(props: SelectProps) { const { @@ -25,13 +25,13 @@ function warningProps(props: SelectProps) { optionLabelProp, } = props; - const multiple = mode === 'multiple' || mode === 'tags'; + const multiple = isMultiple(mode); const mergedShowSearch = showSearch !== undefined ? showSearch : multiple || mode === 'combobox'; const mergedOptions = options || convertChildrenToData(children); // `tags` should not set option as disabled warning( - mode !== 'tags' || mergedOptions.every((opt: any) => !opt.disabled), + mode !== 'tags' || mergedOptions.every((opt: { disabled?: boolean }) => !opt.disabled), 'Please avoid setting option to disabled in tags mode since user can always type text as tag.', ); @@ -67,7 +67,7 @@ function warningProps(props: SelectProps) { ); if (value !== undefined && value !== null) { - const values = toArray(value); + const values = toArray(value); warning( !labelInValue || values.every(val => typeof val === 'object' && ('key' in val || 'value' in val)), diff --git a/components/vc-select2/OptGroup.tsx b/components/vc-select2/OptGroup.tsx deleted file mode 100644 index 00a60e353..000000000 --- a/components/vc-select2/OptGroup.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import type { FunctionalComponent } from 'vue'; - -import type { DefaultOptionType } from './Select'; - -export type OptGroupProps = Omit; - -export interface OptionGroupFC extends FunctionalComponent { - /** Legacy for check if is a Option Group */ - isSelectOptGroup: boolean; -} - -const OptGroup: OptionGroupFC = () => null; -OptGroup.isSelectOptGroup = true; -OptGroup.displayName = 'ASelectOptGroup'; -export default OptGroup; diff --git a/components/vc-select2/Option.tsx b/components/vc-select2/Option.tsx deleted file mode 100644 index 6c98f3287..000000000 --- a/components/vc-select2/Option.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import type { FunctionalComponent } from 'vue'; - -import type { DefaultOptionType } from './Select'; - -export interface OptionProps extends Omit { - /** Save for customize data */ - [prop: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any -} - -export interface OptionFC extends FunctionalComponent { - /** Legacy for check if is a Option Group */ - isSelectOption: boolean; -} - -const Option: OptionFC = () => null; -Option.isSelectOption = true; -Option.displayName = 'ASelectOption'; -export default Option; diff --git a/components/vc-select2/OptionList.tsx b/components/vc-select2/OptionList.tsx deleted file mode 100644 index 7cb1aa68d..000000000 --- a/components/vc-select2/OptionList.tsx +++ /dev/null @@ -1,378 +0,0 @@ -import TransBtn from './TransBtn'; - -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 { computed, defineComponent, nextTick, reactive, watch } from 'vue'; -import List from '../vc-virtual-list'; -import useMemo from '../_util/hooks/useMemo'; -import { isPlatformMac } from './utils/platformUtil'; - -export interface RefOptionListProps { - onKeydown: (e?: KeyboardEvent) => void; - onKeyup: (e?: KeyboardEvent) => void; - scrollTo?: (index: number) => void; -} - -import type { EventHandler } from '../_util/EventInterface'; -import omit from '../_util/omit'; -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({ - name: 'OptionList', - inheritAttrs: false, - slots: ['option'], - setup(_, { expose, slots }) { - const baseProps = useBaseProps(); - const props = useSelectProps(); - const itemPrefixCls = computed(() => `${baseProps.prefixCls}-item`); - - const memoFlattenOptions = useMemo( - () => props.flattenOptions, - [() => baseProps.open, () => props.flattenOptions], - next => next[0], - ); - - // =========================== List =========================== - const listRef = createRef(); - - const onListMouseDown: EventHandler = event => { - event.preventDefault(); - }; - - const scrollIntoView = (index: number) => { - if (listRef.current) { - listRef.current.scrollTo({ index }); - } - }; - - // ========================== Active ========================== - const getEnabledActiveIndex = (index: number, offset = 1) => { - const len = memoFlattenOptions.value.length; - - for (let i = 0; i < len; i += 1) { - const current = (index + i * offset + len) % len; - - const { group, data } = memoFlattenOptions.value[current]; - if (!group && !data.disabled) { - return current; - } - } - - return -1; - }; - const state = reactive({ - activeIndex: getEnabledActiveIndex(0), - }); - - const setActive = (index: number, fromKeyboard = false) => { - state.activeIndex = index; - const info = { source: fromKeyboard ? ('keyboard' as const) : ('mouse' as const) }; - - // Trigger active event - const flattenItem = memoFlattenOptions.value[index]; - if (!flattenItem) { - props.onActiveValue(null, -1, info); - return; - } - - props.onActiveValue(flattenItem.data.value, index, info); - }; - - // Auto active first item when list length or searchValue changed - - watch( - [() => memoFlattenOptions.value.length, () => baseProps.searchValue], - () => { - setActive(props.defaultActiveFirstOption !== false ? getEnabledActiveIndex(0) : -1); - }, - { immediate: true }, - ); - // Auto scroll to item position in single mode - - watch( - [() => baseProps.open, () => baseProps.searchValue], - () => { - 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); - nextTick(() => { - scrollIntoView(index); - }); - } - } - // Force trigger scrollbar visible when open - if (baseProps.open) { - nextTick(() => { - listRef.current?.scrollTo(undefined); - }); - } - }, - { immediate: true, flush: 'post' }, - ); - - // ========================== Values ========================== - const onSelectValue = (value?: RawValueType) => { - if (value !== undefined) { - props.onSelect(value, { selected: !props.rawValues.has(value) }); - } - - // Single mode should always close by select - 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 || {}; - const { value } = itemData; - const { group } = item; - const attrs = pickAttrs(itemData, true); - const mergedLabel = getLabel(item); - return item ? ( -
- {value} -
- ) : null; - } - 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 (which === KeyCode.P) { - offset = -1; - } - } - - if (offset !== 0) { - const nextActiveIndex = getEnabledActiveIndex(state.activeIndex + offset, offset); - scrollIntoView(nextActiveIndex); - setActive(nextActiveIndex, true); - } - - break; - } - - // >>> Select - case KeyCode.ENTER: { - // value - const item = memoFlattenOptions.value[state.activeIndex]; - if (item && !item.data.disabled) { - onSelectValue(item.data.value); - } else { - onSelectValue(undefined); - } - - if (baseProps.open) { - event.preventDefault(); - } - - break; - } - - // >>> Close - case KeyCode.ESC: { - baseProps.toggleOpen(false); - if (baseProps.open) { - event.stopPropagation(); - } - } - } - }; - const onKeyup = () => {}; - - const scrollTo = (index: number) => { - scrollIntoView(index); - }; - 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 ( - <> -
- {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 ( -
{ - 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} - - )} -
- ); - }, - }} - >
- - ); - }; - }, -}); - -export default OptionList; diff --git a/components/vc-select2/Select.tsx b/components/vc-select2/Select.tsx deleted file mode 100644 index 796668dcc..000000000 --- a/components/vc-select2/Select.tsx +++ /dev/null @@ -1,642 +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, 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 = (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[]; -} - -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, - 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() { - 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 ( - >> 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} - /> - ); - }; - }, -}); diff --git a/components/vc-select2/SelectTrigger.tsx b/components/vc-select2/SelectTrigger.tsx deleted file mode 100644 index ed4ed7550..000000000 --- a/components/vc-select2/SelectTrigger.tsx +++ /dev/null @@ -1,193 +0,0 @@ -import Trigger from '../vc-trigger'; -import PropTypes from '../_util/vue-types'; -import classNames from '../_util/classNames'; -import type { CSSProperties } from 'vue'; -import { computed, ref, defineComponent } from 'vue'; -import type { VueNode } from '../_util/type'; -import type { DropdownRender, Placement, RenderDOMFunc } from './BaseSelect'; - -const getBuiltInPlacements = (adjustX: number) => { - return { - bottomLeft: { - points: ['tl', 'bl'], - offset: [0, 4], - overflow: { - adjustX, - adjustY: 1, - }, - }, - bottomRight: { - points: ['tr', 'br'], - offset: [0, 4], - overflow: { - adjustX, - adjustY: 1, - }, - }, - topLeft: { - points: ['bl', 'tl'], - offset: [0, -4], - overflow: { - adjustX, - adjustY: 1, - }, - }, - topRight: { - points: ['br', 'tr'], - offset: [0, -4], - overflow: { - adjustX, - adjustY: 1, - }, - }, - }; -}; - -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; - visible: boolean; - popupElement: VueNode; - animation?: string; - transitionName?: string; - containerWidth: number; - placement?: Placement; - dropdownStyle: CSSProperties; - dropdownClassName: string; - direction: string; - dropdownMatchSelectWidth?: boolean | number; - dropdownRender?: DropdownRender; - getPopupContainer?: RenderDOMFunc; - dropdownAlign: object; - empty: boolean; - autoAdjustOverflow?: boolean; - getTriggerDOMNode: () => any; - onPopupVisibleChange?: (visible: boolean) => void; - - onPopupMouseEnter: () => void; -} - -const SelectTrigger = defineComponent({ - name: 'SelectTrigger', - inheritAttrs: false, - 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`; - - let popupNode = popupElement; - if (dropdownRender) { - popupNode = dropdownRender({ menuNode: popupElement, props }); - } - - const mergedTransitionName = animation ? `${dropdownPrefixCls}-${animation}` : transitionName; - - const popupStyle = { minWidth: `${containerWidth}px`, ...dropdownStyle }; - - if (typeof dropdownMatchSelectWidth === 'number') { - popupStyle.width = `${dropdownMatchSelectWidth}px`; - } else if (dropdownMatchSelectWidth) { - popupStyle.width = `${containerWidth}px`; - } - return ( - ( -
- {popupNode} -
- ), - }} - >
- ); - }; - }, -}); - -export default SelectTrigger; diff --git a/components/vc-select2/Selector/Input.tsx b/components/vc-select2/Selector/Input.tsx deleted file mode 100644 index 9013055cd..000000000 --- a/components/vc-select2/Selector/Input.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import { cloneElement } from '../../_util/vnode'; -import type { VNode } from 'vue'; -import { defineComponent, getCurrentInstance, inject, onMounted, withDirectives } from 'vue'; -import PropTypes from '../../_util/vue-types'; -import type { RefObject } from '../../_util/createRef'; -import antInput from '../../_util/antInputDirective'; -import classNames from '../../_util/classNames'; -import type { EventHandler } from '../../_util/EventInterface'; -import type { VueNode } from '../../_util/type'; - -interface InputProps { - prefixCls: string; - id: string; - inputElement: VueNode; - disabled: boolean; - autofocus: boolean; - autocomplete: string; - editable: boolean; - activeDescendantId?: string; - value: string; - open: boolean; - tabindex: number | string; - /** Pass accessibility props to input */ - attrs: object; - inputRef: RefObject; - onKeydown: EventHandler; - onMousedown: EventHandler; - onChange: EventHandler; - onPaste: EventHandler; - onCompositionstart: EventHandler; - onCompositionend: EventHandler; - onFocus: EventHandler; - onBlur: EventHandler; -} - -const Input = defineComponent({ - name: 'Input', - inheritAttrs: false, - props: { - inputRef: PropTypes.any, - prefixCls: PropTypes.string, - id: PropTypes.string, - inputElement: PropTypes.any, - disabled: PropTypes.looseBool, - autofocus: PropTypes.looseBool, - autocomplete: PropTypes.string, - editable: PropTypes.looseBool, - activeDescendantId: PropTypes.string, - value: PropTypes.string, - open: PropTypes.looseBool, - tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - /** Pass accessibility props to input */ - attrs: PropTypes.object, - onKeydown: PropTypes.func, - onMousedown: PropTypes.func, - onChange: PropTypes.func, - onPaste: PropTypes.func, - onCompositionstart: PropTypes.func, - onCompositionend: PropTypes.func, - onFocus: PropTypes.func, - onBlur: PropTypes.func, - }, - setup(props) { - if (process.env.NODE_ENV === 'test') { - onMounted(() => { - const ins = getCurrentInstance(); - if (props.autofocus) { - if (ins.vnode && ins.vnode.el) { - ins.vnode.el.focus(); - } - } - }); - } - return { - blurTimeout: null, - VCSelectContainerEvent: inject('VCSelectContainerEvent') as any, - }; - }, - render() { - const { - prefixCls, - id, - inputElement, - disabled, - tabindex, - autofocus, - autocomplete, - editable, - activeDescendantId, - value, - onKeydown, - onMousedown, - onChange, - onPaste, - onCompositionstart, - onCompositionend, - onFocus, - onBlur, - open, - inputRef, - attrs, - } = this.$props as InputProps; - let inputNode: any = inputElement || withDirectives(() as VNode, [[antInput]]); - - const inputProps = inputNode.props || {}; - const { - onKeydown: onOriginKeyDown, - onInput: onOriginInput, - onFocus: onOriginFocus, - onBlur: onOriginBlur, - onMousedown: onOriginMouseDown, - onCompositionstart: onOriginCompositionStart, - onCompositionend: onOriginCompositionEnd, - style, - } = inputProps; - inputNode = cloneElement( - inputNode, - Object.assign( - { - id, - ref: inputRef, - disabled, - tabindex, - autocomplete: autocomplete || 'off', - autofocus, - class: classNames(`${prefixCls}-selection-search-input`, inputNode?.props?.className), - style: { ...style, opacity: editable ? null : 0 }, - role: 'combobox', - 'aria-expanded': open, - 'aria-haspopup': 'listbox', - 'aria-owns': `${id}_list`, - 'aria-autocomplete': 'list', - 'aria-controls': `${id}_list`, - 'aria-activedescendant': activeDescendantId, - ...attrs, - value: editable ? value : '', - readonly: !editable, - unselectable: !editable ? 'on' : null, - onKeydown: (event: KeyboardEvent) => { - onKeydown(event); - if (onOriginKeyDown) { - onOriginKeyDown(event); - } - }, - onMousedown: (event: MouseEvent) => { - onMousedown(event); - if (onOriginMouseDown) { - onOriginMouseDown(event); - } - }, - onInput: (event: Event) => { - onChange(event); - if (onOriginInput) { - onOriginInput(event); - } - }, - onCompositionstart(event: CompositionEvent) { - onCompositionstart(event); - if (onOriginCompositionStart) { - onOriginCompositionStart(event); - } - }, - onCompositionend(event: CompositionEvent) { - onCompositionend(event); - if (onOriginCompositionEnd) { - onOriginCompositionEnd(event); - } - }, - onPaste, - onFocus: (...args: any[]) => { - clearTimeout(this.blurTimeout); - onOriginFocus && onOriginFocus(args[0]); - onFocus && onFocus(args[0]); - this.VCSelectContainerEvent?.focus(args[0]); - }, - onBlur: (...args: any[]) => { - this.blurTimeout = setTimeout(() => { - onOriginBlur && onOriginBlur(args[0]); - onBlur && onBlur(args[0]); - this.VCSelectContainerEvent?.blur(args[0]); - }, 200); - }, - }, - inputNode.type === 'textarea' ? {} : { type: 'search' }, - ), - true, - true, - ) as VNode; - return inputNode; - }, -}); - -// Input.props = { -// inputRef: PropTypes.any, -// prefixCls: PropTypes.string, -// id: PropTypes.string, -// inputElement: PropTypes.any, -// disabled: PropTypes.looseBool, -// autofocus: PropTypes.looseBool, -// autocomplete: PropTypes.string, -// editable: PropTypes.looseBool, -// accessibilityIndex: PropTypes.number, -// value: PropTypes.string, -// open: PropTypes.looseBool, -// tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), -// /** Pass accessibility props to input */ -// attrs: PropTypes.object, -// onKeydown: PropTypes.func, -// onMousedown: PropTypes.func, -// onChange: PropTypes.func, -// onPaste: PropTypes.func, -// onCompositionstart: PropTypes.func, -// onCompositionend: PropTypes.func, -// onFocus: PropTypes.func, -// onBlur: PropTypes.func, -// }; - -export default Input; diff --git a/components/vc-select2/Selector/MultipleSelector.tsx b/components/vc-select2/Selector/MultipleSelector.tsx deleted file mode 100644 index 4fddf5dfb..000000000 --- a/components/vc-select2/Selector/MultipleSelector.tsx +++ /dev/null @@ -1,283 +0,0 @@ -import TransBtn from '../TransBtn'; -import type { InnerSelectorProps } from './interface'; -import Input from './Input'; -import type { Ref, PropType } from 'vue'; -import { computed, defineComponent, onMounted, ref, watch } from 'vue'; -import classNames from '../../_util/classNames'; -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'; - -type SelectorProps = InnerSelectorProps & { - // Icon - removeIcon?: RenderNode; - - // Tags - maxTagCount?: number | 'responsive'; - maxTagTextLength?: number; - maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); - tokenSeparators?: string[]; - tagRender?: (props: CustomTagProps) => VueNode; - onToggleOpen: any; - - // Motion - choiceTransitionName?: string; - - // Event - onRemove: (value: DisplayValueType) => void; -}; - -const props = { - id: PropTypes.string, - prefixCls: PropTypes.string, - values: PropTypes.array, - open: PropTypes.looseBool, - searchValue: PropTypes.string, - inputRef: PropTypes.any, - placeholder: PropTypes.any, - disabled: PropTypes.looseBool, - mode: PropTypes.string, - showSearch: PropTypes.looseBool, - autofocus: PropTypes.looseBool, - autocomplete: PropTypes.string, - activeDescendantId: PropTypes.string, - tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - - removeIcon: PropTypes.any, - choiceTransitionName: PropTypes.string, - - maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - maxTagTextLength: PropTypes.number, - maxTagPlaceholder: PropTypes.any.def( - () => (omittedValues: DisplayValueType[]) => `+ ${omittedValues.length} ...`, - ), - tagRender: PropTypes.func, - - onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, - onRemove: PropTypes.func, - onInputChange: PropTypes.func, - onInputPaste: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputMouseDown: PropTypes.func, - onInputCompositionStart: PropTypes.func, - onInputCompositionEnd: PropTypes.func, -}; - -const onPreventMouseDown = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); -}; - -const SelectSelector = defineComponent({ - name: 'MultipleSelectSelector', - inheritAttrs: false, - props: props as any, - setup(props) { - const measureRef = ref(); - const inputWidth = ref(0); - const focused = ref(false); - - const selectionPrefixCls = computed(() => `${props.prefixCls}-selection`); - - // ===================== Search ====================== - const inputValue = computed(() => - props.open || props.mode === 'tags' ? props.searchValue : '', - ); - const inputEditable: Ref = computed( - () => - props.mode === 'tags' || ((props.showSearch && (props.open || focused.value)) as boolean), - ); - - // We measure width and set to the input immediately - onMounted(() => { - watch( - inputValue, - () => { - inputWidth.value = measureRef.value.scrollWidth; - }, - { flush: 'post', immediate: true }, - ); - }); - - // ===================== Render ====================== - // >>> Render Selector Node. Includes Item & Rest - function defaultRenderSelector( - title: VueNode, - content: VueNode, - itemDisabled: boolean, - closable?: boolean, - onClose?: (e: MouseEvent) => void, - ) { - return ( - - {content} - {closable && ( - - × - - )} - - ); - } - - function customizeRenderSelector( - value: RawValueType, - content: VueNode, - itemDisabled: boolean, - closable: boolean, - onClose: (e: MouseEvent) => void, - option: BaseOptionType, - ) { - const onMouseDown = (e: MouseEvent) => { - onPreventMouseDown(e); - props.onToggleOpen(!open); - }; - return ( - - {props.tagRender({ - label: content, - value, - disabled: itemDisabled, - closable, - onClose, - option, - })} - - ); - } - - function renderItem(valueItem: DisplayValueType) { - const { disabled: itemDisabled, label, value, option } = valueItem; - const closable = !props.disabled && !itemDisabled; - - let displayLabel = label; - - if (typeof props.maxTagTextLength === 'number') { - if (typeof label === 'string' || typeof label === 'number') { - const strLabel = String(displayLabel); - - if (strLabel.length > props.maxTagTextLength) { - displayLabel = `${strLabel.slice(0, props.maxTagTextLength)}...`; - } - } - } - const onClose = (event?: MouseEvent) => { - if (event) event.stopPropagation(); - props.onRemove?.(valueItem); - }; - - return typeof props.tagRender === 'function' - ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose, option) - : defaultRenderSelector(label, displayLabel, itemDisabled, closable, onClose); - } - - function renderRest(omittedValues: DisplayValueType[]) { - const { maxTagPlaceholder = omittedValues => `+ ${omittedValues.length} ...` } = props; - const content = - typeof maxTagPlaceholder === 'function' - ? maxTagPlaceholder(omittedValues) - : maxTagPlaceholder; - - return defaultRenderSelector(content, content, false); - } - - return () => { - const { - id, - prefixCls, - values, - open, - inputRef, - placeholder, - disabled, - autofocus, - autocomplete, - activeDescendantId, - tabindex, - onInputChange, - onInputPaste, - onInputKeyDown, - onInputMouseDown, - onInputCompositionStart, - onInputCompositionEnd, - } = props; - - // >>> Input Node - const inputNode = ( -
- (focused.value = true)} - onBlur={() => (focused.value = false)} - /> - - {/* Measure Node */} - - {inputValue.value}  - -
- ); - - // >>> Selections - const selectionNode = ( - - ); - return ( - <> - {selectionNode} - {!values.length && !inputValue.value && ( - {placeholder} - )} - - ); - }; - }, -}); - -export default SelectSelector; diff --git a/components/vc-select2/Selector/SingleSelector.tsx b/components/vc-select2/Selector/SingleSelector.tsx deleted file mode 100644 index a6ecde102..000000000 --- a/components/vc-select2/Selector/SingleSelector.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import pickAttrs from '../../_util/pickAttrs'; -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'; - -interface SelectorProps extends InnerSelectorProps { - inputElement: VueNode; - activeValue: string; -} -const props = { - inputElement: PropTypes.any, - id: PropTypes.string, - prefixCls: PropTypes.string, - values: PropTypes.array, - open: PropTypes.looseBool, - searchValue: PropTypes.string, - inputRef: PropTypes.any, - placeholder: PropTypes.any, - disabled: PropTypes.looseBool, - mode: PropTypes.string, - showSearch: PropTypes.looseBool, - autofocus: PropTypes.looseBool, - autocomplete: PropTypes.string, - activeDescendantId: PropTypes.string, - tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - activeValue: PropTypes.string, - backfill: PropTypes.looseBool, - onInputChange: PropTypes.func, - onInputPaste: PropTypes.func, - onInputKeyDown: PropTypes.func, - onInputMouseDown: PropTypes.func, - onInputCompositionStart: PropTypes.func, - onInputCompositionEnd: PropTypes.func, -}; -const SingleSelector = defineComponent({ - name: 'SingleSelector', - setup(props) { - const inputChanged = ref(false); - - const combobox = computed(() => props.mode === 'combobox'); - const inputEditable = computed(() => combobox.value || props.showSearch); - - const inputValue = computed(() => { - let inputValue: string = props.searchValue || ''; - if (combobox.value && props.activeValue && !inputChanged.value) { - inputValue = props.activeValue; - } - return inputValue; - }); - const treeSelectContext = useInjectTreeSelectContext(); - watch( - [combobox, () => props.activeValue], - () => { - if (combobox.value) { - inputChanged.value = false; - } - }, - { immediate: true }, - ); - - // Not show text when closed expect combobox mode - const hasTextInput = computed(() => - props.mode !== 'combobox' && !props.open && !props.showSearch ? false : !!inputValue.value, - ); - - const title = computed(() => { - const item = props.values[0]; - return item && (typeof item.label === 'string' || typeof item.label === 'number') - ? item.label.toString() - : undefined; - }); - - const renderPlaceholder = () => { - if (props.values[0]) { - return null; - } - const hiddenStyle = hasTextInput.value ? { visibility: 'hidden' as const } : undefined; - return ( - - {props.placeholder} - - ); - }; - - return () => { - const { - inputElement, - prefixCls, - id, - values, - inputRef, - disabled, - autofocus, - autocomplete, - activeDescendantId, - open, - tabindex, - onInputKeyDown, - onInputMouseDown, - onInputChange, - onInputPaste, - onInputCompositionStart, - onInputCompositionEnd, - } = props; - const item = values[0]; - let titleNode = null; - // custom tree-select title by slot - if (item && treeSelectContext.value.slots) { - titleNode = - treeSelectContext.value.slots[item?.option?.data?.slots?.title] || - treeSelectContext.value.slots.title || - item.label; - if (typeof titleNode === 'function') { - titleNode = titleNode(item.option?.data || {}); - } - // else if (treeSelectContext.value.slots.titleRender) { - // // 因历史 title 是覆盖逻辑,新增 titleRender,所有的 title 都走一遍 titleRender - // titleNode = treeSelectContext.value.slots.titleRender(item.option?.data || {}); - // } - } else { - titleNode = item?.label; - } - return ( - <> - - { - inputChanged.value = true; - onInputChange(e as any); - }} - onPaste={onInputPaste} - onCompositionstart={onInputCompositionStart} - onCompositionend={onInputCompositionEnd} - tabindex={tabindex} - attrs={pickAttrs(props, true)} - /> - - - {/* Display value */} - {!combobox.value && item && !hasTextInput.value && ( - - {titleNode} - - )} - - {/* Display placeholder */} - {renderPlaceholder()} - - ); - }; - }, -}); -SingleSelector.props = props; -SingleSelector.inheritAttrs = false; - -export default SingleSelector; diff --git a/components/vc-select2/Selector/index.tsx b/components/vc-select2/Selector/index.tsx deleted file mode 100644 index 6fc30878e..000000000 --- a/components/vc-select2/Selector/index.tsx +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Cursor rule: - * 1. Only `showSearch` enabled - * 2. Only `open` is `true` - * 3. When typing, set `open` to `true` which hit rule of 2 - * - * Accessibility: - * - https://www.w3.org/TR/wai-aria-practices/examples/combobox/aria1.1pattern/listbox-combo.html - */ - -import KeyCode from '../../_util/KeyCode'; -import MultipleSelector from './MultipleSelector'; -import SingleSelector from './SingleSelector'; -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'; -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; - values: DisplayValueType[]; - multiple?: boolean; - mode: Mode; - searchValue: string; - activeValue: string; - inputElement: VueNode; - - autofocus?: boolean; - activeDescendantId?: string; - tabindex?: number | string; - disabled?: boolean; - placeholder?: VueNode; - removeIcon?: RenderNode; - - // Tags - maxTagCount?: number | 'responsive'; - maxTagTextLength?: number; - maxTagPlaceholder?: VueNode | ((omittedValues: DisplayValueType[]) => VueNode); - tagRender?: (props: CustomTagProps) => VueNode; - - /** Check if `tokenSeparators` contains `\n` or `\r\n` */ - tokenWithEnter?: boolean; - - // Motion - choiceTransitionName?: string; - - onToggleOpen: (open?: boolean) => void | any; - /** `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; - onRemove: (value: DisplayValueType) => void; - onInputKeyDown?: (e: KeyboardEvent) => void; - - /** - * @private get real dom for trigger align. - * This may be removed after React provides replacement of `findDOMNode` - */ - domRef: () => HTMLDivElement; -} -export interface RefSelectorProps { - focus: () => void; - blur: () => void; - scrollTo?: ScrollTo; -} - -const Selector = defineComponent({ - name: 'Selector', - inheritAttrs: false, - props: { - id: PropTypes.string, - prefixCls: PropTypes.string, - showSearch: PropTypes.looseBool, - open: PropTypes.looseBool, - /** Display in the Selector value, it's not same as `value` prop */ - values: PropTypes.array, - multiple: PropTypes.looseBool, - mode: PropTypes.string, - searchValue: PropTypes.string, - activeValue: PropTypes.string, - inputElement: PropTypes.any, - - autofocus: PropTypes.looseBool, - activeDescendantId: PropTypes.string, - tabindex: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - disabled: PropTypes.looseBool, - placeholder: PropTypes.any, - removeIcon: PropTypes.any, - - // Tags - maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), - maxTagTextLength: PropTypes.number, - maxTagPlaceholder: PropTypes.any, - tagRender: PropTypes.func, - - /** Check if `tokenSeparators` contains `\n` or `\r\n` */ - tokenWithEnter: PropTypes.looseBool, - - // Motion - choiceTransitionName: PropTypes.string, - - onToggleOpen: { type: Function as PropType<(open?: boolean) => void> }, - /** `onSearch` returns go next step boolean to check if need do toggle open */ - onSearch: PropTypes.func, - onSearchSubmit: PropTypes.func, - onRemove: PropTypes.func, - onInputKeyDown: { type: Function as PropType }, - - /** - * @private get real dom for trigger align. - * This may be removed after React provides replacement of `findDOMNode` - */ - domRef: PropTypes.func, - } as any, - setup(props, { expose }) { - const inputRef = createRef(); - let compositionStatus = false; - - // ====================== Input ====================== - const [getInputMouseDown, setInputMouseDown] = useLock(0); - - const onInternalInputKeyDown = (event: KeyboardEvent) => { - const { which } = event; - - if (which === KeyCode.UP || which === KeyCode.DOWN) { - event.preventDefault(); - } - - if (props.onInputKeyDown) { - props.onInputKeyDown(event); - } - - if (which === KeyCode.ENTER && props.mode === 'tags' && !compositionStatus && !props.open) { - // When menu isn't open, OptionList won't trigger a value change - // So when enter is pressed, the tag's input value should be emitted here to let selector know - props.onSearchSubmit((event.target as HTMLInputElement).value); - } - - if (isValidateOpenKey(which)) { - props.onToggleOpen(true); - } - }; - - /** - * We can not use `findDOMNode` sine it will get warning, - * have to use timer to check if is input element. - */ - const onInternalInputMouseDown = () => { - setInputMouseDown(true); - }; - - // When paste come, ignore next onChange - let pastedText = null; - - const triggerOnSearch = (value: string) => { - if (props.onSearch(value, true, compositionStatus) !== false) { - props.onToggleOpen(true); - } - }; - - const onInputCompositionStart = () => { - compositionStatus = true; - }; - - const onInputCompositionEnd = (e: InputEvent) => { - compositionStatus = false; - // Trigger search again to support `tokenSeparators` with typewriting - if (props.mode !== 'combobox') { - triggerOnSearch((e.target as HTMLInputElement).value); - } - }; - - const onInputChange = (event: { target: { value: any } }) => { - let { - target: { value }, - } = event; - - // Pasted text should replace back to origin content - if (props.tokenWithEnter && pastedText && /[\r\n]/.test(pastedText)) { - // CRLF will be treated as a single space for input element - const replacedText = pastedText - .replace(/[\r\n]+$/, '') - .replace(/\r\n/g, ' ') - .replace(/[\r\n]/g, ' '); - value = value.replace(replacedText, pastedText); - } - - pastedText = null; - - triggerOnSearch(value); - }; - - const onInputPaste = (e: ClipboardEvent) => { - const { clipboardData } = e; - const value = clipboardData.getData('text'); - - pastedText = value; - }; - - const onClick = ({ target }) => { - if (target !== inputRef.current) { - // Should focus input if click the selector - const isIE = (document.body.style as any).msTouchAction !== undefined; - if (isIE) { - setTimeout(() => { - inputRef.current.focus(); - }); - } else { - inputRef.current.focus(); - } - } - }; - - const onMousedown = (event: MouseEvent) => { - const inputMouseDown = getInputMouseDown(); - if (event.target !== inputRef.current && !inputMouseDown) { - event.preventDefault(); - } - - if ((props.mode !== 'combobox' && (!props.showSearch || !inputMouseDown)) || !props.open) { - if (props.open) { - props.onSearch('', true, false); - } - props.onToggleOpen(); - } - }; - expose({ - focus: () => { - inputRef.current.focus(); - }, - blur: () => { - inputRef.current.blur(); - }, - }); - - 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} -
- ); - }; - }, -}); - -export default Selector; diff --git a/components/vc-select2/Selector/interface.ts b/components/vc-select2/Selector/interface.ts deleted file mode 100644 index 18dfd6709..000000000 --- a/components/vc-select2/Selector/interface.ts +++ /dev/null @@ -1,28 +0,0 @@ -import type { RefObject } from '../../_util/createRef'; - -import type { EventHandler } from '../../_util/EventInterface'; -import type { VueNode } from '../../_util/type'; -import type { Mode, DisplayValueType } from '../BaseSelect'; - -export interface InnerSelectorProps { - prefixCls: string; - id: string; - mode: Mode; - inputRef: RefObject; - placeholder?: VueNode; - disabled?: boolean; - autofocus?: boolean; - autocomplete?: string; - values: DisplayValueType[]; - showSearch?: boolean; - searchValue: string; - activeDescendantId: string; - open: boolean; - tabindex?: number | string; - onInputKeyDown: EventHandler; - onInputMouseDown: EventHandler; - onInputChange: EventHandler; - onInputPaste: EventHandler; - onInputCompositionStart: EventHandler; - onInputCompositionEnd: EventHandler; -} diff --git a/components/vc-select2/TransBtn.tsx b/components/vc-select2/TransBtn.tsx deleted file mode 100644 index 6c95ce1d9..000000000 --- a/components/vc-select2/TransBtn.tsx +++ /dev/null @@ -1,66 +0,0 @@ -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: RenderNode; - customizeIconProps?: any; - onMousedown?: (payload: MouseEvent) => void; - onClick?: (payload: MouseEvent) => void; -} - -export interface TransBtnType extends FunctionalComponent { - displayName: string; -} - -const TransBtn: TransBtnType = (props, { slots }) => { - const { class: className, customizeIcon, customizeIconProps, onMousedown, onClick } = props; - let icon: VueNode; - - if (typeof customizeIcon === 'function') { - icon = customizeIcon(customizeIconProps); - } else { - icon = customizeIcon; - } - - return ( - { - event.preventDefault(); - if (onMousedown) { - onMousedown(event); - } - }} - style={{ - userSelect: 'none', - WebkitUserSelect: 'none', - }} - unselectable="on" - onClick={onClick} - aria-hidden - > - {icon !== undefined ? ( - icon - ) : ( - `${cls}-icon`)}> - {slots.default?.()} - - )} - - ); -}; - -TransBtn.inheritAttrs = false; -TransBtn.displayName = 'TransBtn'; -TransBtn.props = { - class: PropTypes.string, - customizeIcon: PropTypes.any, - customizeIconProps: PropTypes.any, - onMousedown: PropTypes.func, - onClick: PropTypes.func, -}; - -export default TransBtn; diff --git a/components/vc-select2/hooks/useDelayReset.ts b/components/vc-select2/hooks/useDelayReset.ts deleted file mode 100644 index ff1e0fcbc..000000000 --- a/components/vc-select2/hooks/useDelayReset.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Ref } from 'vue'; -import { onMounted, ref } from 'vue'; - -/** - * Similar with `useLock`, but this hook will always execute last value. - * When set to `true`, it will keep `true` for a short time even if `false` is set. - */ -export default function useDelayReset( - timeout = 10, -): [Ref, (val: boolean, callback?: () => void) => void, () => void] { - const bool = ref(false); - let delay: any; - - const cancelLatest = () => { - clearTimeout(delay); - }; - - onMounted(() => { - cancelLatest(); - }); - const delaySetBool = (value: boolean, callback: () => void) => { - cancelLatest(); - delay = setTimeout(() => { - bool.value = value; - if (callback) { - callback(); - } - }, timeout); - }; - - return [bool, delaySetBool, cancelLatest]; -} diff --git a/components/vc-select2/hooks/useLock.ts b/components/vc-select2/hooks/useLock.ts deleted file mode 100644 index 5a7266570..000000000 --- a/components/vc-select2/hooks/useLock.ts +++ /dev/null @@ -1,29 +0,0 @@ -import { onBeforeUpdate } from 'vue'; - -/** - * Locker return cached mark. - * If set to `true`, will return `true` in a short time even if set `false`. - * If set to `false` and then set to `true`, will change to `true`. - * And after time duration, it will back to `null` automatically. - */ -export default function useLock(duration = 250): [() => boolean | null, (lock: boolean) => void] { - let lock: boolean | null = null; - let timeout: any; - - onBeforeUpdate(() => { - clearTimeout(timeout); - }); - - function doLock(locked: boolean) { - if (locked || lock === null) { - lock = locked; - } - - clearTimeout(timeout); - timeout = setTimeout(() => { - lock = null; - }, duration); - } - - return [() => lock, doLock]; -} diff --git a/components/vc-select2/hooks/useSelectTriggerControl.ts b/components/vc-select2/hooks/useSelectTriggerControl.ts deleted file mode 100644 index 460d9b9a8..000000000 --- a/components/vc-select2/hooks/useSelectTriggerControl.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { Ref } from 'vue'; -import { onBeforeUnmount, onMounted } from 'vue'; - -export default function useSelectTriggerControl( - refs: Ref[], - open: Ref, - triggerOpen: (open: boolean) => void, -) { - function onGlobalMouseDown(event: MouseEvent) { - let target = event.target as HTMLElement; - - if (target.shadowRoot && event.composed) { - target = (event.composedPath()[0] || target) as HTMLElement; - } - const elements = [refs[0]?.value, refs[1]?.value?.getPopupElement()]; - if ( - open.value && - elements.every(element => element && !element.contains(target) && element !== target) - ) { - // Should trigger close - triggerOpen(false); - } - } - - onMounted(() => { - window.addEventListener('mousedown', onGlobalMouseDown); - }); - - onBeforeUnmount(() => { - window.removeEventListener('mousedown', onGlobalMouseDown); - }); -} diff --git a/components/vc-select2/index.ts b/components/vc-select2/index.ts deleted file mode 100644 index 685603662..000000000 --- a/components/vc-select2/index.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { SelectProps } from './Select'; -import Select, { selectProps } from './Select'; -import Option from './Option'; -import OptGroup from './OptGroup'; -import BaseSelect from './BaseSelect'; -import type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate } from './BaseSelect'; -import useBaseProps from './hooks/useBaseProps'; - -export { Option, OptGroup, selectProps, BaseSelect, useBaseProps }; -export type { BaseSelectProps, BaseSelectRef, BaseSelectPropsWithoutPrivate, SelectProps }; - -export default Select; diff --git a/components/vc-select2/utils/commonUtil.ts b/components/vc-select2/utils/commonUtil.ts deleted file mode 100644 index 6e61f053d..000000000 --- a/components/vc-select2/utils/commonUtil.ts +++ /dev/null @@ -1,12 +0,0 @@ -export function toArray(value: T | T[]): T[] { - if (Array.isArray(value)) { - return value; - } - return value !== undefined ? [value] : []; -} - -export const isClient = - typeof window !== 'undefined' && window.document && window.document.documentElement; - -/** Is client side and not jsdom */ -export const isBrowserClient = process.env.NODE_ENV !== 'test' && isClient; diff --git a/components/vc-select2/utils/legacyUtil.ts b/components/vc-select2/utils/legacyUtil.ts deleted file mode 100644 index f7a58d214..000000000 --- a/components/vc-select2/utils/legacyUtil.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { flattenChildren, isValidElement } from '../../_util/props-util'; -import type { VNode } from 'vue'; -import type { BaseOptionType, DefaultOptionType } from '../Select'; -import type { VueNode } from '../../_util/type'; - -function convertNodeToOption( - node: VNode, -): OptionType { - const { - key, - children, - props: { value, disabled, ...restProps }, - } = node as Omit & { - children: { default?: () => any }; - key: string | number; - }; - const child = children && children.default ? children.default() : undefined; - return { - key, - value: value !== undefined ? value : key, - children: child, - disabled: disabled || disabled === '', // support - ...(restProps as any), - }; -} - -export function convertChildrenToData( - nodes: VueNode[], - optionOnly = false, -): OptionType[] { - const dd = flattenChildren(nodes as []) - .map((node: VNode, index: number): OptionType | null => { - if (!isValidElement(node) || !node.type) { - return null; - } - - const { - type: { isSelectOptGroup }, - key, - children, - props, - } = node as VNode & { - type: { isSelectOptGroup?: boolean }; - children: { default?: () => any; label?: () => any }; - }; - - if (optionOnly || !isSelectOptGroup) { - return convertNodeToOption(node); - } - const child = children && children.default ? children.default() : undefined; - const label = props?.label || children.label?.() || key; - return { - key: `__RC_SELECT_GRP__${key === null ? index : String(key)}__`, - ...props, - label, - options: convertChildrenToData(child || []), - } as any; - }) - .filter(data => data); - return dd; -} diff --git a/components/vc-select2/utils/platformUtil.ts b/components/vc-select2/utils/platformUtil.ts deleted file mode 100644 index f6bdcc68b..000000000 --- a/components/vc-select2/utils/platformUtil.ts +++ /dev/null @@ -1,4 +0,0 @@ -/* istanbul ignore file */ -export function isPlatformMac(): boolean { - return /(mac\sos|macintosh)/i.test(navigator.appVersion); -} diff --git a/components/vc-select2/utils/valueUtil.ts b/components/vc-select2/utils/valueUtil.ts deleted file mode 100644 index ca21cff9e..000000000 --- a/components/vc-select2/utils/valueUtil.ts +++ /dev/null @@ -1,128 +0,0 @@ -import type { BaseOptionType, DefaultOptionType, RawValueType, FieldNames } from '../Select'; -import { warning } from '../../vc-util/warning'; -import type { FlattenOptionData } from '../interface'; - -function getKey(data: BaseOptionType, index: number) { - const { key } = data; - let value: RawValueType; - - if ('value' in data) { - ({ value } = data); - } - - if (key !== null && key !== undefined) { - return key; - } - if (value !== undefined) { - return value; - } - return `rc-index-key-${index}`; -} - -export function fillFieldNames(fieldNames: FieldNames | undefined, childrenAsData: boolean) { - const { label, value, options } = fieldNames || {}; - - return { - label: label || (childrenAsData ? 'children' : 'label'), - value: value || 'value', - options: options || 'options', - }; -} - -/** - * Flat options into flatten list. - * We use `optionOnly` here is aim to avoid user use nested option group. - * Here is simply set `key` to the index if not provided. - */ -export function flattenOptions( - options: OptionType[], - { fieldNames, childrenAsData }: { fieldNames?: FieldNames; childrenAsData?: boolean } = {}, -): FlattenOptionData[] { - const flattenList: FlattenOptionData[] = []; - - const { - label: fieldLabel, - value: fieldValue, - options: fieldOptions, - } = fillFieldNames(fieldNames, false); - - function dig(list: OptionType[], isGroupOption: boolean) { - list.forEach(data => { - const label = data[fieldLabel]; - - if (isGroupOption || !(fieldOptions in data)) { - const value = data[fieldValue]; - // Option - flattenList.push({ - key: getKey(data, flattenList.length), - groupOption: isGroupOption, - data, - label, - value, - }); - } else { - let grpLabel = label; - if (grpLabel === undefined && childrenAsData) { - grpLabel = data.label; - } - // Option Group - flattenList.push({ - key: getKey(data, flattenList.length), - group: true, - data, - label: grpLabel, - }); - - dig(data[fieldOptions], true); - } - }); - } - - dig(options, false); - - return flattenList; -} - -/** - * Inject `props` into `option` for legacy usage - */ -export function injectPropsWithOption(option: T): T { - const newOption = { ...option }; - if (!('props' in newOption)) { - Object.defineProperty(newOption, 'props', { - get() { - warning( - false, - 'Return type is option instead of Option instance. Please read value directly instead of reading from `props`.', - ); - return newOption; - }, - }); - } - - return newOption; -} - -export function getSeparatedContent(text: string, tokens: string[]): string[] { - if (!tokens || !tokens.length) { - return null; - } - - let match = false; - - function separate(str: string, [token, ...restTokens]: string[]) { - if (!token) { - return [str]; - } - - const list = str.split(token); - match = match || list.length > 1; - - return list - .reduce((prevList, unitStr) => [...prevList, ...separate(unitStr, restTokens)], []) - .filter(unit => unit); - } - - const list = separate(text, tokens); - return match ? list : null; -} diff --git a/components/vc-select2/utils/warningPropsUtil.ts b/components/vc-select2/utils/warningPropsUtil.ts deleted file mode 100644 index aa7ce292d..000000000 --- a/components/vc-select2/utils/warningPropsUtil.ts +++ /dev/null @@ -1,135 +0,0 @@ -import warning, { noteOnce } from '../../vc-util/warning'; -import { convertChildrenToData } from './legacyUtil'; -import { toArray } from './commonUtil'; -import { isValidElement } from '../../_util/props-util'; -import type { VNode } from 'vue'; -import type { RawValueType, LabelInValueType, SelectProps } from '../Select'; -import { isMultiple } from '../BaseSelect'; - -function warningProps(props: SelectProps) { - const { - mode, - options, - children, - backfill, - allowClear, - placeholder, - getInputElement, - showSearch, - onSearch, - defaultOpen, - autofocus, - labelInValue, - value, - inputValue, - optionLabelProp, - } = props; - - const multiple = isMultiple(mode); - const mergedShowSearch = showSearch !== undefined ? showSearch : multiple || mode === 'combobox'; - const mergedOptions = options || convertChildrenToData(children); - - // `tags` should not set option as disabled - warning( - mode !== 'tags' || mergedOptions.every((opt: { disabled?: boolean }) => !opt.disabled), - 'Please avoid setting option to disabled in tags mode since user can always type text as tag.', - ); - - // `combobox` should not use `optionLabelProp` - warning( - mode !== 'combobox' || !optionLabelProp, - '`combobox` mode not support `optionLabelProp`. Please set `value` on Option directly.', - ); - - // Only `combobox` support `backfill` - warning(mode === 'combobox' || !backfill, '`backfill` only works with `combobox` mode.'); - - // Only `combobox` support `getInputElement` - warning( - mode === 'combobox' || !getInputElement, - '`getInputElement` only work with `combobox` mode.', - ); - - // Customize `getInputElement` should not use `allowClear` & `placeholder` - noteOnce( - mode !== 'combobox' || !getInputElement || !allowClear || !placeholder, - 'Customize `getInputElement` should customize clear and placeholder logic instead of configuring `allowClear` and `placeholder`.', - ); - - // `onSearch` should use in `combobox` or `showSearch` - if (onSearch && !mergedShowSearch && mode !== 'combobox' && mode !== 'tags') { - warning(false, '`onSearch` should work with `showSearch` instead of use alone.'); - } - - noteOnce( - !defaultOpen || autofocus, - '`defaultOpen` makes Select open without focus which means it will not close by click outside. You can set `autofocus` if needed.', - ); - - if (value !== undefined && value !== null) { - const values = toArray(value); - warning( - !labelInValue || - values.every(val => typeof val === 'object' && ('key' in val || 'value' in val)), - '`value` should in shape of `{ value: string | number, label?: any }` when you set `labelInValue` to `true`', - ); - - warning( - !multiple || Array.isArray(value), - '`value` should be array when `mode` is `multiple` or `tags`', - ); - } - - // Syntactic sugar should use correct children type - if (children) { - let invalidateChildType = null; - children.some((node: any) => { - if (!isValidElement(node) || !node.type) { - return false; - } - - const { type } = node as { type: { isSelectOption?: boolean; isSelectOptGroup?: boolean } }; - - if (type.isSelectOption) { - return false; - } - if (type.isSelectOptGroup) { - const childs = node.children?.default() || []; - const allChildrenValid = childs.every((subNode: VNode) => { - if ( - !isValidElement(subNode) || - !node.type || - (subNode.type as { isSelectOption?: boolean }).isSelectOption - ) { - return true; - } - invalidateChildType = subNode.type; - return false; - }); - - if (allChildrenValid) { - return false; - } - return true; - } - invalidateChildType = type; - return true; - }); - - if (invalidateChildType) { - warning( - false, - `\`children\` should be \`Select.Option\` or \`Select.OptGroup\` instead of \`${ - invalidateChildType.displayName || invalidateChildType.name || invalidateChildType - }\`.`, - ); - } - - warning( - inputValue === undefined, - '`inputValue` is deprecated, please use `searchValue` instead.', - ); - } -} - -export default warningProps; diff --git a/components/vc-tree-select/index.tsx b/components/vc-tree-select/index.tsx index 8c1da04f4..d3e52682a 100644 --- a/components/vc-tree-select/index.tsx +++ b/components/vc-tree-select/index.tsx @@ -1,3 +1,4 @@ +// base rc-tree-select@4.6.1 import TreeSelect from './TreeSelect'; import TreeNode from './TreeNode'; import { SHOW_ALL, SHOW_CHILD, SHOW_PARENT } from './utils/strategyUtil';