diff --git a/components/select/index.tsx b/components/select/index.tsx index 30c2a8bc77..dc03286e66 100644 --- a/components/select/index.tsx +++ b/components/select/index.tsx @@ -20,7 +20,7 @@ export interface LabeledValue { label: VNodeChild; } export type SizeType = 'small' | 'middle' | 'large' | undefined; -export type SelectValue = RawValue | RawValue[] | LabeledValue | LabeledValue[]; +export type SelectValue = RawValue | RawValue[] | LabeledValue | LabeledValue[] | undefined; export interface InternalSelectProps extends Omit, 'mode'> { suffixIcon?: VNodeChild; diff --git a/components/select/style/index.less b/components/select/style/index.less index 1c87429533..d2ae04a76c 100644 --- a/components/select/style/index.less +++ b/components/select/style/index.less @@ -37,6 +37,10 @@ background: @input-disabled-bg; cursor: not-allowed; + .@{select-prefix-cls}-multiple& { + background: @select-multiple-disabled-background; + } + input { cursor: not-allowed; } @@ -66,7 +70,12 @@ display: inline-block; cursor: pointer; - &:not(.@{select-prefix-cls}-disabled):hover &-selector { + &:not(&-customize-input) &-selector { + .select-selector(); + .select-search-input-without-border(); + } + + &:not(&-disabled):hover &-selector { .hover(); } @@ -93,6 +102,7 @@ color: @input-placeholder-color; white-space: nowrap; text-overflow: ellipsis; + pointer-events: none; // IE11 css hack. `*::-ms-backdrop,` is a must have @media all and (-ms-high-contrast: none) { @@ -189,21 +199,21 @@ outline: none; box-shadow: @box-shadow-base; - &.slide-up-enter.slide-up-enter-active&-placement-bottomLeft, - &.slide-up-appear.slide-up-appear-active&-placement-bottomLeft { + &.@{ant-prefix}-slide-up-enter.@{ant-prefix}-slide-up-enter-active&-placement-bottomLeft, + &.@{ant-prefix}-slide-up-appear.@{ant-prefix}-slide-up-appear-active&-placement-bottomLeft { animation-name: antSlideUpIn; } - &.slide-up-enter.slide-up-enter-active&-placement-topLeft, - &.slide-up-appear.slide-up-appear-active&-placement-topLeft { + &.@{ant-prefix}-slide-up-enter.@{ant-prefix}-slide-up-enter-active&-placement-topLeft, + &.@{ant-prefix}-slide-up-appear.@{ant-prefix}-slide-up-appear-active&-placement-topLeft { animation-name: antSlideDownIn; } - &.slide-up-leave.slide-up-leave-active&-placement-bottomLeft { + &.@{ant-prefix}-slide-up-leave.@{ant-prefix}-slide-up-leave-active&-placement-bottomLeft { animation-name: antSlideUpOut; } - &.slide-up-leave.slide-up-leave-active&-placement-topLeft { + &.@{ant-prefix}-slide-up-leave.@{ant-prefix}-slide-up-leave-active&-placement-topLeft { animation-name: antSlideDownOut; } diff --git a/components/select/style/multiple.less b/components/select/style/multiple.less index ac5a25874b..737c30b7d9 100644 --- a/components/select/style/multiple.less +++ b/components/select/style/multiple.less @@ -1,5 +1,6 @@ @import './index'; +@select-overflow-prefix-cls: ~'@{select-prefix-cls}-selection-overflow'; @select-multiple-item-border-width: 1px; @select-multiple-padding: max( @@ -13,13 +14,25 @@ * since chrome may update to redesign with its align logic. */ +// =========================== Overflow =========================== +.@{select-overflow-prefix-cls} { + position: relative; + display: flex; + flex: auto; + flex-wrap: wrap; + max-width: 100%; + + &-item { + flex: none; + align-self: center; + max-width: 100%; + } +} + .@{select-prefix-cls} { &-multiple { // ========================= Selector ========================= .@{select-prefix-cls}-selector { - .select-selector(); - .select-search-input-without-border(); - display: flex; flex-wrap: wrap; align-items: center; @@ -59,9 +72,7 @@ height: @select-multiple-item-height; margin-top: @select-multiple-item-spacing-half; - margin-right: @input-padding-vertical-base; margin-bottom: @select-multiple-item-spacing-half; - padding: 0 (@padding-xs / 2) 0 @padding-xs; line-height: @select-multiple-item-height - @select-multiple-item-border-width * 2; background: @select-selection-item-bg; border: 1px solid @select-selection-item-border-color; @@ -69,6 +80,9 @@ cursor: default; transition: font-size 0.3s, line-height 0.3s, height 0.3s; user-select: none; + margin-inline-end: @input-padding-vertical-base; + padding-inline-start: @padding-xs; + padding-inline-end: (@padding-xs / 2); .@{select-prefix-cls}-disabled& { color: @select-multiple-item-disabled-color; @@ -81,7 +95,7 @@ display: inline-block; margin-right: (@padding-xs / 2); overflow: hidden; - white-space: nowrap; + white-space: pre; // fix whitespace wrapping. custom tags display all whitespace within. text-overflow: ellipsis; } @@ -90,10 +104,9 @@ display: inline-block; color: @text-color-secondary; font-weight: bold; - font-size: @font-size-sm; + font-size: 10px; line-height: inherit; cursor: pointer; - .iconfont-size-under-12px(10px); > .@{iconfont-css-prefix} { vertical-align: -0.2em; @@ -106,14 +119,24 @@ } // ========================== Input ========================== + .@{select-overflow-prefix-cls}-item + .@{select-overflow-prefix-cls}-item { + .@{select-prefix-cls}-selection-search { + margin-inline-start: 0; + } + } + .@{select-prefix-cls}-selection-search { position: relative; - margin-left: (@select-multiple-padding / 2); + max-width: 100%; + margin-top: @select-multiple-item-spacing-half; + margin-bottom: @select-multiple-item-spacing-half; + margin-inline-start: @input-padding-horizontal-base - @input-padding-vertical-base; &-input, &-mirror { + height: @select-multiple-item-height; font-family: @font-family; - line-height: @line-height-base; + line-height: @select-multiple-item-height; transition: all 0.3s; } @@ -127,14 +150,9 @@ top: 0; left: 0; z-index: 999; - white-space: nowrap; + white-space: pre; // fix whitespace wrapping caused width calculation bug visibility: hidden; } - - // https://github.com/ant-design/ant-design/issues/22906 - &:first-child .@{select-prefix-cls}-selection-search-input { - margin-left: 6.5px; - } } // ======================= Placeholder ======================= @@ -166,8 +184,8 @@ } .@{select-prefix-cls}-selection-search { - height: @select-selection-height + @select-multiple-padding; - line-height: @select-selection-height + @select-multiple-padding; + height: @select-selection-height; + line-height: @select-selection-height; &-input, &-mirror { @@ -186,10 +204,9 @@ .@{select-prefix-cls}-selection-placeholder { left: @input-padding-horizontal-sm; } - // https://github.com/ant-design/ant-design/issues/22906 - .@{select-prefix-cls}-selection-search:first-child - .@{select-prefix-cls}-selection-search-input { - margin-left: 3px; + // https://github.com/ant-design/ant-design/issues/29559 + .@{select-prefix-cls}-selection-search { + margin-inline-start: 3px; } } &.@{select-prefix-cls}-lg { diff --git a/components/select/style/rtl.less b/components/select/style/rtl.less index 9562784f01..3497c899b7 100644 --- a/components/select/style/rtl.less +++ b/components/select/style/rtl.less @@ -66,9 +66,6 @@ // ======================== Selections ======================== .@{select-prefix-cls}-selection-item { .@{select-prefix-cls}-rtl& { - margin-right: 0; - margin-left: @input-padding-vertical-base; - padding: 0 @padding-xs 0 (@padding-xs / 2); text-align: right; } // It's ok not to do this, but 24px makes bottom narrow in view should adjust @@ -83,11 +80,6 @@ // ========================== Input ========================== .@{select-prefix-cls}-selection-search { - .@{select-prefix-cls}-rtl& { - margin-right: (@select-multiple-padding / 2); - margin-left: @input-padding-vertical-base; - } - &-mirror { .@{select-prefix-cls}-rtl& { right: 0; @@ -119,7 +111,7 @@ } // single -@selection-item-padding: ceil((@font-size-base * 1.25)); +@selection-item-padding: ceil(@font-size-base * 1.25); .@{select-prefix-cls}-single { // ========================= Selector ========================= @@ -150,18 +142,6 @@ } } - // ========================== Input ========================== - // We only change the style of non-customize input which is only support by `combobox` mode. - - // Not customize - &:not(.@{select-prefix-cls}-customize-input) { - .@{select-prefix-cls}-selector { - .@{select-prefix-cls}-rtl& { - padding: 0 @input-padding-horizontal-base; - } - } - } - // ============================================================ // == Size == // ============================================================ @@ -172,7 +152,7 @@ // With arrow should provides `padding-right` to show the arrow &.@{select-prefix-cls}-show-arrow .@{select-prefix-cls}-selection-search { .@{select-prefix-cls}-rtl& { - right: 0; + right: @input-padding-horizontal-sm - 1px; } } diff --git a/components/select/style/single.less b/components/select/style/single.less index 8adb73c6de..e70052a15f 100644 --- a/components/select/style/single.less +++ b/components/select/style/single.less @@ -1,6 +1,6 @@ @import './index'; -@selection-item-padding: ceil((@font-size-base * 1.25)); +@selection-item-padding: ceil(@font-size-base * 1.25); .@{select-prefix-cls}-single { // ========================= Selector ========================= @@ -76,10 +76,7 @@ // Not customize &:not(.@{select-prefix-cls}-customize-input) { .@{select-prefix-cls}-selector { - .select-selector(); - .select-search-input-without-border(); width: 100%; - height: @input-height-base; padding: 0 @input-padding-horizontal-base; diff --git a/components/vc-overflow/Item.tsx b/components/vc-overflow/Item.tsx index a45dd70216..af7274105c 100644 --- a/components/vc-overflow/Item.tsx +++ b/components/vc-overflow/Item.tsx @@ -90,19 +90,17 @@ export default defineComponent({ ); - if (responsive) { - itemNode = ( - { - internalRegisterSize(offsetWidth); - }} - > - {itemNode} - - ); - } - - return itemNode; + // 使用 disabled 避免结构不一致 导致子组件 rerender + return ( + { + internalRegisterSize(offsetWidth); + }} + > + {itemNode} + + ); }; }, }); diff --git a/components/vc-overflow/Overflow.tsx b/components/vc-overflow/Overflow.tsx index 578f1381fb..f067a44f94 100644 --- a/components/vc-overflow/Overflow.tsx +++ b/components/vc-overflow/Overflow.tsx @@ -370,12 +370,12 @@ const Overflow = defineComponent({ )} ); - - if (isResponsive.value) { - overflowNode = {overflowNode}; - } - - return overflowNode; + // 使用 disabled 避免结构不一致 导致子组件 rerender + return ( + + {overflowNode} + + ); }; }, }); diff --git a/components/vc-select/OptionList.tsx b/components/vc-select/OptionList.tsx index a9a1885078..f5335fea38 100644 --- a/components/vc-select/OptionList.tsx +++ b/components/vc-select/OptionList.tsx @@ -237,6 +237,9 @@ const OptionList = defineComponent({ // >>> Close case KeyCode.ESC: { props.onToggleOpen(false); + if (props.open) { + event.stopPropagation(); + } } } }, diff --git a/components/vc-select/Select.tsx b/components/vc-select/Select.tsx index 1f08585e79..0e96eeada3 100644 --- a/components/vc-select/Select.tsx +++ b/components/vc-select/Select.tsx @@ -46,7 +46,6 @@ import generateSelector, { SelectProps } from './generate'; import { DefaultValueType } from './interface/generator'; import warningProps from './utils/warningPropsUtil'; import { defineComponent, ref } from 'vue'; -import { getSlot } from '../_util/props-util'; import omit from 'lodash-es/omit'; const RefSelect = generateSelector({ @@ -69,21 +68,27 @@ export type ExportedSelectProps< > = SelectProps; const Select = defineComponent>({ - setup() { + setup(props, { attrs, expose, slots }) { const selectRef = ref(null); - return { - selectRef, + expose({ focus: () => { selectRef.value?.focus(); }, blur: () => { selectRef.value?.blur(); }, + }); + return () => { + return ( + + ); }; }, - render() { - return ; - }, }); Select.inheritAttrs = false; Select.props = omit(RefSelect.props, ['children']); diff --git a/components/vc-select/Selector/Input.tsx b/components/vc-select/Selector/Input.tsx index 6c925a9dfd..8250217ec4 100644 --- a/components/vc-select/Selector/Input.tsx +++ b/components/vc-select/Selector/Input.tsx @@ -11,6 +11,7 @@ import { import PropTypes from '../../_util/vue-types'; import { RefObject } from '../../_util/createRef'; import antInput from '../../_util/antInputDirective'; +import classNames from 'ant-design-vue/es/_util/classNames'; interface InputProps { prefixCls: string; @@ -33,6 +34,8 @@ interface InputProps { onPaste: EventHandlerNonNull; onCompositionstart: EventHandlerNonNull; onCompositionend: EventHandlerNonNull; + onFocus: EventHandlerNonNull; + onBlur: EventHandlerNonNull; } const Input = defineComponent({ @@ -72,6 +75,8 @@ const Input = defineComponent { 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); }, @@ -181,6 +192,8 @@ Input.props = { onPaste: PropTypes.func, onCompositionstart: PropTypes.func, onCompositionend: PropTypes.func, + onFocus: PropTypes.func, + onBlur: PropTypes.func, }; export default Input; diff --git a/components/vc-select/Selector/MultipleSelector.tsx b/components/vc-select/Selector/MultipleSelector.tsx index 01d460624f..fe60ff7608 100644 --- a/components/vc-select/Selector/MultipleSelector.tsx +++ b/components/vc-select/Selector/MultipleSelector.tsx @@ -1,35 +1,32 @@ import TransBtn from '../TransBtn'; -import { LabelValueType, RawValueType, CustomTagProps } from '../interface/generator'; +import { + LabelValueType, + RawValueType, + CustomTagProps, + DefaultValueType, + DisplayLabelValueType, +} from '../interface/generator'; import { RenderNode } from '../interface'; import { InnerSelectorProps } from '.'; import Input from './Input'; -import { - computed, - defineComponent, - onMounted, - ref, - VNodeChild, - watch, - watchEffect, - Ref, -} from 'vue'; +import { computed, defineComponent, onMounted, ref, VNodeChild, watch, Ref } from 'vue'; import classNames from '../../_util/classNames'; import pickAttrs from '../../_util/pickAttrs'; import PropTypes from '../../_util/vue-types'; -import { getTransitionGroupProps, TransitionGroup } from '../../_util/transition'; - -const REST_TAG_KEY = '__RC_SELECT_MAX_REST_COUNT__'; +import { VueNode } from 'ant-design-vue/es/_util/type'; +import Overflow from '../../vc-overflow'; interface SelectorProps extends InnerSelectorProps { // Icon removeIcon?: RenderNode; // Tags - maxTagCount?: number; + maxTagCount?: number | 'responsive'; maxTagTextLength?: number; maxTagPlaceholder?: VNodeChild; tokenSeparators?: string[]; tagRender?: (props: CustomTagProps) => VNodeChild; + onToggleOpen: (open?: boolean) => void; // Motion choiceTransitionName?: string; @@ -57,7 +54,7 @@ const props = { removeIcon: PropTypes.VNodeChild, choiceTransitionName: PropTypes.string, - maxTagCount: PropTypes.number, + maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxTagTextLength: PropTypes.number, maxTagPlaceholder: PropTypes.any.def(() => (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, @@ -73,24 +70,27 @@ const props = { onInputCompositionEnd: PropTypes.func, }; +const onPreventMouseDown = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); +}; + const SelectSelector = defineComponent({ name: 'MultipleSelectSelector', setup(props) { - let motionAppear = false; // not need use ref, because not need trigger watchEffect const measureRef = ref(); const inputWidth = ref(0); + const focused = ref(false); - // ===================== Motion ====================== - onMounted(() => { - motionAppear = true; - }); + 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.open && props.showSearch) as boolean), + () => + props.mode === 'tags' || ((props.showSearch && (props.open || focused.value)) as boolean), ); // We measure width and set to the input immediately @@ -100,122 +100,99 @@ const SelectSelector = defineComponent({ () => { inputWidth.value = measureRef.value.scrollWidth; }, - { flush: 'post' }, + { flush: 'post', immediate: true }, ); }); - const selectionNode = ref(); - watchEffect(() => { - const { - values, - prefixCls, - removeIcon, - choiceTransitionName, - maxTagCount, - maxTagTextLength, - maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, - tagRender, - onSelect, - } = props; - // ==================== Selection ==================== - let displayValues: LabelValueType[] = values; + // ===================== Render ====================== + // >>> Render Selector Node. Includes Item & Rest + function defaultRenderSelector( + content: VueNode, + itemDisabled: boolean, + closable?: boolean, + onClose?: (e: MouseEvent) => void, + ) { + return ( + + {content} + {closable && ( + + × + + )} + + ); + } - // Cut by `maxTagCount` - let restCount: number; - if (typeof maxTagCount === 'number') { - restCount = values.length - maxTagCount; - displayValues = values.slice(0, maxTagCount); - } + function customizeRenderSelector( + value: DefaultValueType, + content: VueNode, + itemDisabled: boolean, + closable: boolean, + onClose: (e: MouseEvent) => void, + ) { + const onMouseDown = (e: MouseEvent) => { + onPreventMouseDown(e); + props.onToggleOpen(!open); + }; + + return ( + + {props.tagRender({ + label: content, + value, + disabled: itemDisabled, + closable, + onClose, + })} + + ); + } - // Update by `maxTagTextLength` - if (typeof maxTagTextLength === 'number') { - displayValues = displayValues.map(({ label, ...rest }) => { - let displayLabel = label; + function renderItem({ disabled: itemDisabled, label, value }: DisplayLabelValueType) { + const closable = !props.disabled && !itemDisabled; - if (typeof label === 'string' || typeof label === 'number') { - const strLabel = String(displayLabel); + let displayLabel = label; - if (strLabel.length > maxTagTextLength) { - displayLabel = `${strLabel.slice(0, maxTagTextLength)}...`; - } - } + if (typeof props.maxTagTextLength === 'number') { + if (typeof label === 'string' || typeof label === 'number') { + const strLabel = String(displayLabel); - return { - ...rest, - label: displayLabel, - }; - }); + if (strLabel.length > props.maxTagTextLength) { + displayLabel = `${strLabel.slice(0, props.maxTagTextLength)}...`; + } + } } + const onClose = (event?: MouseEvent) => { + if (event) event.stopPropagation(); + props.onSelect(value, { selected: false }); + }; - // Fill rest - if (restCount > 0) { - displayValues.push({ - key: REST_TAG_KEY, - label: - typeof maxTagPlaceholder === 'function' - ? maxTagPlaceholder(values.slice(maxTagCount)) - : maxTagPlaceholder, - }); - } - const transitionProps = getTransitionGroupProps(choiceTransitionName, { - appear: motionAppear, - }); - selectionNode.value = ( - - {...displayValues.map( - ({ key, label, value, disabled: itemDisabled, class: className, style }) => { - const mergedKey = key || value; - const closable = key !== REST_TAG_KEY && !itemDisabled; - const onMousedown = (event: MouseEvent) => { - event.preventDefault(); - event.stopPropagation(); - }; - const onClose = (event?: MouseEvent) => { - if (event) event.stopPropagation(); - onSelect(value as RawValueType, { selected: false }); - }; + return typeof props.tagRender === 'function' + ? customizeRenderSelector(value, displayLabel, itemDisabled, closable, onClose) + : defaultRenderSelector(displayLabel, itemDisabled, closable, onClose); + } - return typeof tagRender === 'function' ? ( - - {tagRender({ - label, - value, - disabled: itemDisabled, - closable, - onClose, - } as CustomTagProps)} - - ) : ( - - {label} - {closable && ( - - × - - )} - - ); - }, - )} - - ); - }); + function renderRest(omittedValues: DisplayLabelValueType[]) { + const { + maxTagPlaceholder = (omittedValues: LabelValueType[]) => `+ ${omittedValues.length} ...`, + } = props; + const content = + typeof maxTagPlaceholder === 'function' + ? maxTagPlaceholder(omittedValues) + : maxTagPlaceholder; + + return defaultRenderSelector(content, false); + } return () => { const { @@ -237,40 +214,63 @@ const SelectSelector = defineComponent({ onInputCompositionStart, onInputCompositionEnd, } = props; - return ( - <> - {selectionNode.value} - - - {/* Measure Node */} - - {inputValue.value}  - + // >>> 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} + {placeholder} )} ); diff --git a/components/vc-select/Selector/index.tsx b/components/vc-select/Selector/index.tsx index 5a95df143b..0560617761 100644 --- a/components/vc-select/Selector/index.tsx +++ b/components/vc-select/Selector/index.tsx @@ -63,7 +63,7 @@ export interface SelectorProps { removeIcon?: RenderNode; // Tags - maxTagCount?: number; + maxTagCount?: number | 'responsive'; maxTagTextLength?: number; maxTagPlaceholder?: VNodeChild; tagRender?: (props: CustomTagProps) => VNodeChild; @@ -140,8 +140,12 @@ const Selector = defineComponent({ compositionStatus = true; }; - const onInputCompositionEnd = () => { + 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 } }) => { @@ -152,7 +156,10 @@ const Selector = defineComponent({ // 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/g, ' ').replace(/[\r\n]/g, ' '); + const replacedText = pastedText + .replace(/[\r\n]+$/, '') + .replace(/\r\n/g, ' ') + .replace(/[\r\n]/g, ' '); value = value.replace(replacedText, pastedText); } @@ -271,7 +278,7 @@ Selector.props = { removeIcon: PropTypes.any, // Tags - maxTagCount: PropTypes.number, + maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxTagTextLength: PropTypes.number, maxTagPlaceholder: PropTypes.any, tagRender: PropTypes.func, diff --git a/components/vc-select/generate.tsx b/components/vc-select/generate.tsx index d47647206e..b0c39b273e 100644 --- a/components/vc-select/generate.tsx +++ b/components/vc-select/generate.tsx @@ -56,6 +56,7 @@ import createRef from '../_util/createRef'; import PropTypes, { withUndefined } from '../_util/vue-types'; import initDefaultProps from '../_util/props-util/initDefaultProps'; import warning from '../_util/warning'; +import isMobile from '../vc-util/isMobile'; const DEFAULT_OMIT_PROPS = [ 'children', @@ -67,6 +68,7 @@ const DEFAULT_OMIT_PROPS = [ 'maxTagPlaceholder', 'choiceTransitionName', 'onInputKeyDown', + 'tabindex', ]; export const BaseProps = () => ({ @@ -94,6 +96,7 @@ export const BaseProps = () => ({ * It's by design. */ filterOption: PropTypes.any, + filterSort: PropTypes.func, showSearch: PropTypes.looseBool, autoClearSearchValue: PropTypes.looseBool, onSearch: PropTypes.func, @@ -134,7 +137,7 @@ export const BaseProps = () => ({ getInputElement: PropTypes.func, optionLabelProp: PropTypes.string, maxTagTextLength: PropTypes.number, - maxTagCount: PropTypes.number, + maxTagCount: PropTypes.oneOfType([PropTypes.number, PropTypes.string]), maxTagPlaceholder: PropTypes.any, tokenSeparators: PropTypes.array, tagRender: PropTypes.func, @@ -195,6 +198,7 @@ export interface SelectProps { * It's by design. */ filterOption?: boolean | FilterFunc; + filterSort?: (optionA: OptionsType[number], optionB: OptionsType[number]) => number; showSearch?: boolean; autoClearSearchValue?: boolean; onSearch?: (value: string) => void; @@ -235,7 +239,7 @@ export interface SelectProps { getInputElement?: () => VNodeChild | JSX.Element; optionLabelProp?: string; maxTagTextLength?: number; - maxTagCount?: number; + maxTagCount?: number | 'responsive'; maxTagPlaceholder?: VNodeChild | ((omittedValues: LabelValueType[]) => VNodeChild); tokenSeparators?: string[]; tagRender?: (props: CustomTagProps) => VNodeChild; @@ -385,6 +389,11 @@ export default function generateSelector< : isMultiple.value || props.mode === 'combobox', ); + const mobile = ref(false); + onMounted(() => { + mobile.value = isMobile(); + }); + // ============================== Ref =============================== const selectorDomRef = createRef(); @@ -399,12 +408,14 @@ export default function generateSelector< // ============================= Value ============================== /** Unique raw values */ - const mergedRawValue = computed(() => + 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)); @@ -457,7 +468,7 @@ export default function generateSelector< const mergedFlattenOptions = computed(() => flattenOptions(mergedOptions.value, props)); - const getValueOption = useCacheOptions(mergedRawValue.value, mergedFlattenOptions); + const getValueOption = useCacheOptions(mergedFlattenOptions); // Display options for OptionList const displayOptions = computed(() => { @@ -484,6 +495,9 @@ export default function generateSelector< key: '__RC_SELECT_TAG_PLACEHOLDER__', }); } + if (props.filterSort && Array.isArray(filteredOptions)) { + return ([...filteredOptions] as OptionsType).sort(props.filterSort); + } return filteredOptions; }); @@ -507,7 +521,7 @@ export default function generateSelector< const valueOptions = getValueOption([val]); const displayValue = getLabeledValue(val, { options: valueOptions, - prevValue: mergedValue.value, + prevValueMap: mergedValueMap.value, labelInValue: mergedLabelInValue.value, optionLabelProp: mergedOptionLabelProp.value, }); @@ -542,7 +556,7 @@ export default function generateSelector< const selectValue = (mergedLabelInValue.value ? getLabeledValue(newValue, { options: newValueOption, - prevValue: mergedValue.value, + prevValueMap: mergedValueMap.value, labelInValue: mergedLabelInValue.value, optionLabelProp: mergedOptionLabelProp.value, }) @@ -583,7 +597,7 @@ export default function generateSelector< labelInValue: mergedLabelInValue.value, options: newRawValuesOptions, getLabeledValue, - prevValue: mergedValue.value, + prevValueMap: mergedValueMap.value, optionLabelProp: mergedOptionLabelProp.value, }); @@ -770,6 +784,10 @@ export default function generateSelector< // 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]), ); @@ -815,9 +833,17 @@ export default function generateSelector< const onInternalKeyDown = (event: KeyboardEvent) => { const clearLock = getClearLock(); const { which } = event; - // We only manage open state here, close logic should handle by list component - if (!mergedOpen.value && which === KeyCode.ENTER) { - onToggleOpen(true); + + 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); @@ -922,7 +948,6 @@ export default function generateSelector< 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(() => { @@ -933,7 +958,7 @@ export default function generateSelector< cancelSetMockFocused(); - if (!popupElement.contains(document.activeElement)) { + if (!mobile.value && !popupElement.contains(document.activeElement)) { selectorRef.value.focus(); } }); @@ -993,6 +1018,7 @@ export default function generateSelector< return { focus, blur, + scrollTo: listRef.value?.scrollTo, tokenWithEnter, mockFocused, mergedId, diff --git a/components/vc-select/hooks/useCacheDisplayValue.ts b/components/vc-select/hooks/useCacheDisplayValue.ts index 0c3dccebfb..087a65fdbb 100644 --- a/components/vc-select/hooks/useCacheDisplayValue.ts +++ b/components/vc-select/hooks/useCacheDisplayValue.ts @@ -17,7 +17,7 @@ export default function useCacheDisplayValue( const resultValues = values.value.map(item => { const cacheLabel = valueLabels.get(item.value); - if (item.value === item.label && cacheLabel) { + if (item.isCacheable && cacheLabel) { return { ...item, label: cacheLabel, diff --git a/components/vc-select/hooks/useCacheOptions.ts b/components/vc-select/hooks/useCacheOptions.ts index 5e3e17b54b..5abac14524 100644 --- a/components/vc-select/hooks/useCacheOptions.ts +++ b/components/vc-select/hooks/useCacheOptions.ts @@ -8,7 +8,7 @@ export default function useCacheOptions< key?: Key; disabled?: boolean; }[] ->(_values: RawValueType[], options: Ref) { +>(options: Ref) { const optionMap = computed(() => { const map: Map[number]> = new Map(); options.value.forEach((item: any) => { diff --git a/components/vc-select/hooks/useSelectTriggerControl.ts b/components/vc-select/hooks/useSelectTriggerControl.ts index 2f2efe26b3..7f7f78135d 100644 --- a/components/vc-select/hooks/useSelectTriggerControl.ts +++ b/components/vc-select/hooks/useSelectTriggerControl.ts @@ -6,7 +6,11 @@ export default function useSelectTriggerControl( triggerOpen: (open: boolean) => void, ) { function onGlobalMouseDown(event: MouseEvent) { - const target = event.target as HTMLElement; + 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 && diff --git a/components/vc-select/interface/generator.ts b/components/vc-select/interface/generator.ts index 165a726a25..5ef5846bdc 100644 --- a/components/vc-select/interface/generator.ts +++ b/components/vc-select/interface/generator.ts @@ -1,5 +1,4 @@ import { VueNode } from '../../_util/type'; -import { VNodeChild } from 'vue'; export type SelectSource = 'option' | 'selection' | 'input'; @@ -13,7 +12,8 @@ export type RawValueType = string | number | null; export interface LabelValueType extends Record { key?: Key; value?: RawValueType; - label?: VNodeChild; + label?: VueNode; + isCacheable?: boolean; } export type DefaultValueType = RawValueType | RawValueType[] | LabelValueType | LabelValueType[]; @@ -23,10 +23,10 @@ export interface DisplayLabelValueType extends LabelValueType { export type SingleType = MixType extends (infer Single)[] ? Single : MixType; -export type OnClear = () => void; +export type OnClear = () => any; export type CustomTagProps = { - label: DefaultValueType; + label: VueNode; value: DefaultValueType; disabled: boolean; onClose: (event?: MouseEvent) => void; @@ -38,7 +38,7 @@ export type GetLabeledValue = ( value: RawValueType, config: { options: FOT; - prevValue: DefaultValueType; + prevValueMap: Map; labelInValue: boolean; optionLabelProp: string; }, diff --git a/components/vc-select/utils/commonUtil.ts b/components/vc-select/utils/commonUtil.ts index f9ab327043..7825305c0d 100644 --- a/components/vc-select/utils/commonUtil.ts +++ b/components/vc-select/utils/commonUtil.ts @@ -19,20 +19,28 @@ export function toArray(value: T | T[]): T[] { export function toInnerValue( value: DefaultValueType, { labelInValue, combobox }: { labelInValue: boolean; combobox: boolean }, -): RawValueType[] { +): [RawValueType[], Map] { + const valueMap = new Map(); if (value === undefined || (value === '' && combobox)) { - return []; + return [[], valueMap]; } const values = Array.isArray(value) ? value : [value]; + let rawValues = values as RawValueType[]; + if (labelInValue) { - return (values as LabelValueType[]).map(({ key, value: val }: LabelValueType) => - val !== undefined ? val : key, - ); + 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 values as RawValueType[]; + return [rawValues, valueMap]; } /** @@ -43,7 +51,7 @@ export function toOuterValues( { optionLabelProp, labelInValue, - prevValue, + prevValueMap, options, getLabeledValue, }: { @@ -51,7 +59,7 @@ export function toOuterValues( labelInValue: boolean; getLabeledValue: GetLabeledValue; options: FOT; - prevValue: DefaultValueType; + prevValueMap: Map; }, ): RawValueType[] | LabelValueType[] | DefaultValueType { let values: DefaultValueType = valueList; @@ -60,7 +68,7 @@ export function toOuterValues( values = values.map(val => getLabeledValue(val, { options, - prevValue, + prevValueMap, labelInValue, optionLabelProp, }), diff --git a/components/vc-select/utils/valueUtil.ts b/components/vc-select/utils/valueUtil.ts index ae9f68b406..d14579a7d8 100644 --- a/components/vc-select/utils/valueUtil.ts +++ b/components/vc-select/utils/valueUtil.ts @@ -120,24 +120,14 @@ export function findValueOption( export const getLabeledValue: GetLabeledValue = ( value, - { options, prevValue, labelInValue, optionLabelProp }, + { options, prevValueMap, labelInValue, optionLabelProp }, ) => { const item = findValueOption([value], options)[0]; const result: LabelValueType = { value, }; - let prevValItem: LabelValueType; - const prevValues = toArray(prevValue as LabelValueType); - if (labelInValue) { - prevValItem = prevValues.find((prevItem: LabelValueType) => { - if (typeof prevItem === 'object' && 'value' in prevItem) { - return prevItem.value === value; - } - // [Legacy] Support `key` as `value` - return prevItem.key === value; - }) as LabelValueType; - } + const prevValItem: LabelValueType = labelInValue ? prevValueMap.get(value) : undefined; if (prevValItem && typeof prevValItem === 'object' && 'label' in prevValItem) { result.label = prevValItem.label; @@ -160,6 +150,7 @@ export const getLabeledValue: GetLabeledValue = ( } } else { result.label = value; + result.isCacheable = true; } // Used for motion control @@ -211,7 +202,7 @@ export function filterOptions( let filterFunc: FilterFunc; if (filterOption === false) { - return options; + return [...options]; } if (typeof filterOption === 'function') { filterFunc = filterOption; diff --git a/components/vc-util/isMobile.ts b/components/vc-util/isMobile.ts new file mode 100644 index 0000000000..8a49470d33 --- /dev/null +++ b/components/vc-util/isMobile.ts @@ -0,0 +1,18 @@ +export default () => { + if (typeof navigator === 'undefined' || typeof window === 'undefined') { + return false; + } + + const agent = navigator.userAgent || navigator.vendor || (window as any).opera; + if ( + /(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino|android|ipad|playbook|silk/i.test( + agent, + ) || + /1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw-(n|u)|c55\/|capi|ccwa|cdm-|cell|chtm|cldc|cmd-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc-s|devi|dica|dmob|do(c|p)o|ds(12|-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(-|_)|g1 u|g560|gene|gf-5|g-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd-(m|p|t)|hei-|hi(pt|ta)|hp( i|ip)|hs-c|ht(c(-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i-(20|go|ma)|i230|iac( |-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|-[a-w])|libw|lynx|m1-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|-([1-8]|c))|phil|pire|pl(ay|uc)|pn-2|po(ck|rt|se)|prox|psio|pt-g|qa-a|qc(07|12|21|32|60|-[2-7]|i-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h-|oo|p-)|sdk\/|se(c(-|0|1)|47|mc|nd|ri)|sgh-|shar|sie(-|m)|sk-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h-|v-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl-|tdg-|tel(i|m)|tim-|t-mo|to(pl|sh)|ts(70|m-|m3|m5)|tx-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas-|your|zeto|zte-/i.test( + agent?.substr(0, 4), + ) + ) { + return true; + } + return false; +}; diff --git a/typings/vue-tsx-shim.d.ts b/typings/vue-tsx-shim.d.ts index 67a7cf4548..2272663045 100644 --- a/typings/vue-tsx-shim.d.ts +++ b/typings/vue-tsx-shim.d.ts @@ -37,5 +37,6 @@ declare module 'vue' { onKeydown?: EventHandler; onKeyup?: EventHandler; onDeselect?: EventHandler; + onClear?: EventHandler; } }