2022-01-21 13:58:10 +00:00
|
|
|
|
import { getSeparatedContent } from './utils/valueUtil';
|
|
|
|
|
import type { RefTriggerProps } from './SelectTrigger';
|
|
|
|
|
import SelectTrigger from './SelectTrigger';
|
|
|
|
|
import type { RefSelectorProps } from './Selector';
|
|
|
|
|
import Selector from './Selector';
|
|
|
|
|
import useSelectTriggerControl from './hooks/useSelectTriggerControl';
|
|
|
|
|
import useDelayReset from './hooks/useDelayReset';
|
|
|
|
|
import TransBtn from './TransBtn';
|
|
|
|
|
import useLock from './hooks/useLock';
|
|
|
|
|
import type { BaseSelectContextProps } from './hooks/useBaseProps';
|
|
|
|
|
import { useProvideBaseSelectProps } from './hooks/useBaseProps';
|
|
|
|
|
import type { Key, VueNode } from '../_util/type';
|
|
|
|
|
import type {
|
|
|
|
|
FocusEventHandler,
|
|
|
|
|
KeyboardEventHandler,
|
|
|
|
|
MouseEventHandler,
|
|
|
|
|
} from '../_util/EventInterface';
|
|
|
|
|
import type { ScrollTo } from '../vc-virtual-list/List';
|
|
|
|
|
import {
|
|
|
|
|
computed,
|
|
|
|
|
defineComponent,
|
|
|
|
|
getCurrentInstance,
|
|
|
|
|
onBeforeUnmount,
|
|
|
|
|
onMounted,
|
|
|
|
|
provide,
|
|
|
|
|
ref,
|
|
|
|
|
toRefs,
|
|
|
|
|
watch,
|
|
|
|
|
watchEffect,
|
|
|
|
|
} from 'vue';
|
|
|
|
|
import type { CSSProperties, ExtractPropTypes, PropType, VNode } from 'vue';
|
|
|
|
|
import PropTypes from '../_util/vue-types';
|
|
|
|
|
import { initDefaultProps, isValidElement } from '../_util/props-util';
|
|
|
|
|
import isMobile from '../vc-util/isMobile';
|
|
|
|
|
import KeyCode from '../_util/KeyCode';
|
|
|
|
|
import { toReactive } from '../_util/toReactive';
|
|
|
|
|
import classNames from '../_util/classNames';
|
|
|
|
|
import createRef from '../_util/createRef';
|
|
|
|
|
import type { BaseOptionType } from './Select';
|
|
|
|
|
import useInjectLegacySelectContext from '../vc-tree-select/LegacyContext';
|
|
|
|
|
import { cloneElement } from '../_util/vnode';
|
|
|
|
|
|
|
|
|
|
const DEFAULT_OMIT_PROPS = [
|
|
|
|
|
'value',
|
|
|
|
|
'onChange',
|
|
|
|
|
'removeIcon',
|
|
|
|
|
'placeholder',
|
|
|
|
|
'autofocus',
|
|
|
|
|
'maxTagCount',
|
|
|
|
|
'maxTagTextLength',
|
|
|
|
|
'maxTagPlaceholder',
|
|
|
|
|
'choiceTransitionName',
|
|
|
|
|
'onInputKeyDown',
|
|
|
|
|
'onPopupScroll',
|
|
|
|
|
'tabindex',
|
|
|
|
|
'OptionList',
|
|
|
|
|
'notFoundContent',
|
|
|
|
|
] as const;
|
|
|
|
|
|
|
|
|
|
export type RenderNode = VueNode | ((props: any) => VueNode);
|
|
|
|
|
|
|
|
|
|
export type RenderDOMFunc = (props: any) => HTMLElement;
|
|
|
|
|
|
|
|
|
|
export type Mode = 'multiple' | 'tags' | 'combobox';
|
|
|
|
|
|
|
|
|
|
export type Placement = 'bottomLeft' | 'bottomRight' | 'topLeft' | 'topRight';
|
|
|
|
|
|
|
|
|
|
export type RawValueType = string | number;
|
|
|
|
|
|
|
|
|
|
export interface RefOptionListProps {
|
|
|
|
|
onKeydown: KeyboardEventHandler;
|
|
|
|
|
onKeyup: KeyboardEventHandler;
|
|
|
|
|
scrollTo?: (index: number) => void;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type CustomTagProps = {
|
|
|
|
|
label: any;
|
|
|
|
|
value: any;
|
|
|
|
|
disabled: boolean;
|
|
|
|
|
onClose: (event?: MouseEvent) => void;
|
|
|
|
|
closable: boolean;
|
|
|
|
|
option: BaseOptionType;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export interface DisplayValueType {
|
|
|
|
|
key?: Key;
|
|
|
|
|
value?: RawValueType;
|
|
|
|
|
label?: any;
|
|
|
|
|
disabled?: boolean;
|
|
|
|
|
option?: BaseOptionType;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export type BaseSelectRef = {
|
|
|
|
|
focus: () => void;
|
|
|
|
|
blur: () => void;
|
|
|
|
|
scrollTo: ScrollTo;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const baseSelectPrivateProps = () => {
|
|
|
|
|
return {
|
|
|
|
|
prefixCls: String,
|
|
|
|
|
id: String,
|
|
|
|
|
omitDomProps: Array as PropType<string[]>,
|
|
|
|
|
|
|
|
|
|
// >>> Value
|
|
|
|
|
displayValues: Array as PropType<DisplayValueType[]>,
|
|
|
|
|
onDisplayValuesChange: Function as PropType<
|
|
|
|
|
(
|
|
|
|
|
values: DisplayValueType[],
|
|
|
|
|
info: {
|
|
|
|
|
type: 'add' | 'remove' | 'clear';
|
|
|
|
|
values: DisplayValueType[];
|
|
|
|
|
},
|
|
|
|
|
) => void
|
|
|
|
|
>,
|
|
|
|
|
|
|
|
|
|
// >>> Active
|
|
|
|
|
/** Current dropdown list active item string value */
|
|
|
|
|
activeValue: String,
|
|
|
|
|
/** Link search input with target element */
|
|
|
|
|
activeDescendantId: String,
|
|
|
|
|
onActiveValueChange: Function as PropType<(value: string | null) => void>,
|
|
|
|
|
|
|
|
|
|
// >>> Search
|
|
|
|
|
searchValue: String,
|
|
|
|
|
/** Trigger onSearch, return false to prevent trigger open event */
|
|
|
|
|
onSearch: Function as PropType<
|
|
|
|
|
(
|
|
|
|
|
searchValue: string,
|
|
|
|
|
info: {
|
|
|
|
|
source:
|
|
|
|
|
| 'typing' //User typing
|
|
|
|
|
| 'effect' // Code logic trigger
|
|
|
|
|
| 'submit' // tag mode only
|
|
|
|
|
| 'blur'; // Not trigger event
|
|
|
|
|
},
|
|
|
|
|
) => void
|
|
|
|
|
>,
|
|
|
|
|
/** Trigger when search text match the `tokenSeparators`. Will provide split content */
|
|
|
|
|
onSearchSplit: Function as PropType<(words: string[]) => void>,
|
|
|
|
|
maxLength: Number,
|
|
|
|
|
|
|
|
|
|
OptionList: PropTypes.any,
|
|
|
|
|
|
|
|
|
|
/** Tell if provided `options` is empty */
|
|
|
|
|
emptyOptions: Boolean,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type DropdownObject = {
|
|
|
|
|
menuNode?: VueNode;
|
|
|
|
|
props?: Record<string, any>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type DropdownRender = (opt?: DropdownObject) => VueNode;
|
|
|
|
|
export const baseSelectPropsWithoutPrivate = () => {
|
|
|
|
|
return {
|
|
|
|
|
showSearch: { type: Boolean, default: undefined },
|
|
|
|
|
tagRender: { type: Function as PropType<(props: CustomTagProps) => any> },
|
2022-01-27 07:12:15 +00:00
|
|
|
|
optionLabelRender: { type: Function as PropType<(option: Record<string, any>) => any> },
|
2022-01-21 13:58:10 +00:00
|
|
|
|
direction: { type: String as PropType<'ltr' | 'rtl'> },
|
|
|
|
|
|
|
|
|
|
// MISC
|
|
|
|
|
tabindex: Number,
|
|
|
|
|
autofocus: Boolean,
|
|
|
|
|
notFoundContent: PropTypes.any,
|
|
|
|
|
placeholder: PropTypes.any,
|
|
|
|
|
onClear: Function as PropType<() => void>,
|
|
|
|
|
|
|
|
|
|
choiceTransitionName: String,
|
|
|
|
|
|
|
|
|
|
// >>> Mode
|
|
|
|
|
mode: String as PropType<Mode>,
|
|
|
|
|
|
|
|
|
|
// >>> Status
|
|
|
|
|
disabled: { type: Boolean, default: undefined },
|
|
|
|
|
loading: { type: Boolean, default: undefined },
|
|
|
|
|
|
|
|
|
|
// >>> Open
|
|
|
|
|
open: { type: Boolean, default: undefined },
|
|
|
|
|
defaultOpen: { type: Boolean, default: undefined },
|
|
|
|
|
onDropdownVisibleChange: { type: Function as PropType<(open: boolean) => void> },
|
|
|
|
|
|
|
|
|
|
// >>> Customize Input
|
|
|
|
|
/** @private Internal usage. Do not use in your production. */
|
|
|
|
|
getInputElement: { type: Function as PropType<() => any> },
|
|
|
|
|
/** @private Internal usage. Do not use in your production. */
|
|
|
|
|
getRawInputElement: { type: Function as PropType<() => any> },
|
|
|
|
|
|
|
|
|
|
// >>> Selector
|
|
|
|
|
maxTagTextLength: Number,
|
|
|
|
|
maxTagCount: { type: [String, Number] as PropType<number | 'responsive'> },
|
|
|
|
|
maxTagPlaceholder: PropTypes.any,
|
|
|
|
|
|
|
|
|
|
// >>> Search
|
|
|
|
|
tokenSeparators: { type: Array as PropType<string[]> },
|
|
|
|
|
|
|
|
|
|
// >>> Icons
|
|
|
|
|
allowClear: { type: Boolean, default: undefined },
|
|
|
|
|
showArrow: { type: Boolean, default: undefined },
|
|
|
|
|
inputIcon: PropTypes.any,
|
|
|
|
|
/** Clear all icon */
|
|
|
|
|
clearIcon: PropTypes.any,
|
|
|
|
|
/** Selector remove icon */
|
|
|
|
|
removeIcon: PropTypes.any,
|
|
|
|
|
|
|
|
|
|
// >>> Dropdown
|
|
|
|
|
animation: String,
|
|
|
|
|
transitionName: String,
|
|
|
|
|
dropdownStyle: { type: Object as PropType<CSSProperties> },
|
|
|
|
|
dropdownClassName: String,
|
|
|
|
|
dropdownMatchSelectWidth: {
|
|
|
|
|
type: [Boolean, Number] as PropType<boolean | number>,
|
|
|
|
|
default: undefined,
|
|
|
|
|
},
|
|
|
|
|
dropdownRender: { type: Function as PropType<DropdownRender> },
|
|
|
|
|
dropdownAlign: PropTypes.any,
|
|
|
|
|
placement: {
|
|
|
|
|
type: String as PropType<Placement>,
|
|
|
|
|
},
|
|
|
|
|
getPopupContainer: { type: Function as PropType<RenderDOMFunc> },
|
|
|
|
|
|
|
|
|
|
// >>> Focus
|
|
|
|
|
showAction: { type: Array as PropType<('focus' | 'click')[]> },
|
|
|
|
|
onBlur: { type: Function as PropType<(e: FocusEvent) => void> },
|
|
|
|
|
onFocus: { type: Function as PropType<(e: FocusEvent) => void> },
|
|
|
|
|
|
|
|
|
|
// >>> Rest Events
|
|
|
|
|
onKeyup: Function as PropType<(e: KeyboardEvent) => void>,
|
|
|
|
|
onKeydown: Function as PropType<(e: KeyboardEvent) => void>,
|
|
|
|
|
onMousedown: Function as PropType<(e: MouseEvent) => void>,
|
|
|
|
|
onPopupScroll: Function as PropType<(e: UIEvent) => void>,
|
|
|
|
|
onInputKeyDown: Function as PropType<(e: KeyboardEvent) => void>,
|
|
|
|
|
onMouseenter: Function as PropType<(e: MouseEvent) => void>,
|
|
|
|
|
onMouseleave: Function as PropType<(e: MouseEvent) => void>,
|
|
|
|
|
onClick: Function as PropType<(e: MouseEvent) => void>,
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
const baseSelectProps = () => {
|
|
|
|
|
return {
|
|
|
|
|
...baseSelectPrivateProps(),
|
|
|
|
|
...baseSelectPropsWithoutPrivate(),
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export type BaseSelectPrivateProps = Partial<
|
|
|
|
|
ExtractPropTypes<ReturnType<typeof baseSelectPrivateProps>>
|
|
|
|
|
>;
|
|
|
|
|
|
|
|
|
|
export type BaseSelectProps = Partial<ExtractPropTypes<ReturnType<typeof baseSelectProps>>>;
|
|
|
|
|
|
|
|
|
|
export type BaseSelectPropsWithoutPrivate = Omit<BaseSelectProps, keyof BaseSelectPrivateProps>;
|
|
|
|
|
|
|
|
|
|
export function isMultiple(mode: Mode) {
|
|
|
|
|
return mode === 'tags' || mode === 'multiple';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default defineComponent({
|
|
|
|
|
name: 'BaseSelect',
|
|
|
|
|
inheritAttrs: false,
|
|
|
|
|
props: initDefaultProps(baseSelectProps(), { showAction: [], notFoundContent: 'Not Found' }),
|
|
|
|
|
setup(props, { attrs, expose, slots }) {
|
|
|
|
|
const multiple = computed(() => isMultiple(props.mode));
|
|
|
|
|
|
|
|
|
|
const mergedShowSearch = computed(() =>
|
|
|
|
|
props.showSearch !== undefined
|
|
|
|
|
? props.showSearch
|
|
|
|
|
: multiple.value || props.mode === 'combobox',
|
|
|
|
|
);
|
|
|
|
|
const mobile = ref(false);
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
mobile.value = isMobile();
|
|
|
|
|
});
|
|
|
|
|
const legacyTreeSelectContext = useInjectLegacySelectContext();
|
|
|
|
|
// ============================== Refs ==============================
|
|
|
|
|
const containerRef = ref<HTMLDivElement>(null);
|
|
|
|
|
const selectorDomRef = createRef();
|
|
|
|
|
const triggerRef = ref<RefTriggerProps>(null);
|
|
|
|
|
const selectorRef = ref<RefSelectorProps>(null);
|
|
|
|
|
const listRef = ref<RefOptionListProps>(null);
|
|
|
|
|
|
|
|
|
|
/** Used for component focused management */
|
|
|
|
|
const [mockFocused, setMockFocused, cancelSetMockFocused] = useDelayReset();
|
|
|
|
|
|
|
|
|
|
const focus = () => {
|
|
|
|
|
selectorRef.value?.focus();
|
|
|
|
|
};
|
|
|
|
|
const blur = () => {
|
|
|
|
|
selectorRef.value?.blur();
|
|
|
|
|
};
|
|
|
|
|
expose({
|
|
|
|
|
focus,
|
|
|
|
|
blur,
|
|
|
|
|
scrollTo: arg => listRef.value?.scrollTo(arg),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const mergedSearchValue = computed(() => {
|
|
|
|
|
if (props.mode !== 'combobox') {
|
|
|
|
|
return props.searchValue;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const val = props.displayValues[0]?.value;
|
|
|
|
|
|
|
|
|
|
return typeof val === 'string' || typeof val === 'number' ? String(val) : '';
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================== Open ==============================
|
|
|
|
|
const initOpen = props.open !== undefined ? props.open : props.defaultOpen;
|
|
|
|
|
const innerOpen = ref(initOpen);
|
|
|
|
|
const mergedOpen = ref(initOpen);
|
|
|
|
|
const setInnerOpen = (val: boolean) => {
|
|
|
|
|
innerOpen.value = props.open !== undefined ? props.open : val;
|
|
|
|
|
mergedOpen.value = innerOpen.value;
|
|
|
|
|
};
|
|
|
|
|
watch(
|
|
|
|
|
() => props.open,
|
|
|
|
|
() => {
|
|
|
|
|
setInnerOpen(props.open);
|
|
|
|
|
},
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// Not trigger `open` in `combobox` when `notFoundContent` is empty
|
|
|
|
|
const emptyListContent = computed(() => !props.notFoundContent && props.emptyOptions);
|
|
|
|
|
|
|
|
|
|
watchEffect(() => {
|
|
|
|
|
mergedOpen.value = innerOpen.value;
|
|
|
|
|
if (
|
|
|
|
|
props.disabled ||
|
|
|
|
|
(emptyListContent.value && mergedOpen.value && props.mode === 'combobox')
|
|
|
|
|
) {
|
|
|
|
|
mergedOpen.value = false;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const triggerOpen = computed(() => (emptyListContent.value ? false : mergedOpen.value));
|
|
|
|
|
|
|
|
|
|
const onToggleOpen = (newOpen?: boolean) => {
|
|
|
|
|
const nextOpen = newOpen !== undefined ? newOpen : !mergedOpen.value;
|
|
|
|
|
|
|
|
|
|
if (innerOpen.value !== nextOpen && !props.disabled) {
|
|
|
|
|
setInnerOpen(nextOpen);
|
|
|
|
|
if (props.onDropdownVisibleChange) {
|
|
|
|
|
props.onDropdownVisibleChange(nextOpen);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const tokenWithEnter = computed(() =>
|
|
|
|
|
(props.tokenSeparators || []).some(tokenSeparator => ['\n', '\r\n'].includes(tokenSeparator)),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const onInternalSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => {
|
|
|
|
|
let ret = true;
|
|
|
|
|
let newSearchText = searchText;
|
|
|
|
|
props.onActiveValueChange?.(null);
|
|
|
|
|
|
|
|
|
|
// Check if match the `tokenSeparators`
|
|
|
|
|
const patchLabels: string[] = isCompositing
|
|
|
|
|
? null
|
|
|
|
|
: getSeparatedContent(searchText, props.tokenSeparators);
|
|
|
|
|
|
|
|
|
|
// Ignore combobox since it's not split-able
|
|
|
|
|
if (props.mode !== 'combobox' && patchLabels) {
|
|
|
|
|
newSearchText = '';
|
|
|
|
|
|
|
|
|
|
props.onSearchSplit?.(patchLabels);
|
|
|
|
|
|
|
|
|
|
// Should close when paste finish
|
|
|
|
|
onToggleOpen(false);
|
|
|
|
|
|
|
|
|
|
// Tell Selector that break next actions
|
|
|
|
|
ret = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.onSearch && mergedSearchValue.value !== newSearchText) {
|
|
|
|
|
props.onSearch(newSearchText, {
|
|
|
|
|
source: fromTyping ? 'typing' : 'effect',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return ret;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Only triggered when menu is closed & mode is tags
|
|
|
|
|
// If menu is open, OptionList will take charge
|
|
|
|
|
// If mode isn't tags, press enter is not meaningful when you can't see any option
|
|
|
|
|
const onInternalSearchSubmit = (searchText: string) => {
|
|
|
|
|
// prevent empty tags from appearing when you click the Enter button
|
|
|
|
|
if (!searchText || !searchText.trim()) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
props.onSearch?.(searchText, { source: 'submit' });
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// Close will clean up single mode search text
|
|
|
|
|
watch(
|
|
|
|
|
mergedOpen,
|
|
|
|
|
() => {
|
|
|
|
|
if (!mergedOpen.value && !multiple.value && props.mode !== 'combobox') {
|
|
|
|
|
onInternalSearch('', false, false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ============================ Disabled ============================
|
|
|
|
|
// Close dropdown & remove focus state when disabled change
|
|
|
|
|
watch(
|
|
|
|
|
() => props.disabled,
|
|
|
|
|
() => {
|
|
|
|
|
if (innerOpen.value && !!props.disabled) {
|
|
|
|
|
setInnerOpen(false);
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ============================ Keyboard ============================
|
|
|
|
|
/**
|
|
|
|
|
* We record input value here to check if can press to clean up by backspace
|
|
|
|
|
* - null: Key is not down, this is reset by key up
|
|
|
|
|
* - true: Search text is empty when first time backspace down
|
|
|
|
|
* - false: Search text is not empty when first time backspace down
|
|
|
|
|
*/
|
|
|
|
|
const [getClearLock, setClearLock] = useLock();
|
|
|
|
|
|
|
|
|
|
// KeyDown
|
|
|
|
|
const onInternalKeyDown: KeyboardEventHandler = (event, ...rest) => {
|
|
|
|
|
const clearLock = getClearLock();
|
|
|
|
|
const { which } = event;
|
|
|
|
|
|
|
|
|
|
if (which === KeyCode.ENTER) {
|
|
|
|
|
// Do not submit form when type in the input
|
|
|
|
|
if (props.mode !== 'combobox') {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// We only manage open state here, close logic should handle by list component
|
|
|
|
|
if (!mergedOpen.value) {
|
|
|
|
|
onToggleOpen(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
setClearLock(!!mergedSearchValue.value);
|
|
|
|
|
|
|
|
|
|
// Remove value by `backspace`
|
|
|
|
|
if (
|
|
|
|
|
which === KeyCode.BACKSPACE &&
|
|
|
|
|
!clearLock &&
|
|
|
|
|
multiple.value &&
|
|
|
|
|
!mergedSearchValue.value &&
|
|
|
|
|
props.displayValues.length
|
|
|
|
|
) {
|
|
|
|
|
const cloneDisplayValues = [...props.displayValues];
|
|
|
|
|
let removedDisplayValue = null;
|
|
|
|
|
|
|
|
|
|
for (let i = cloneDisplayValues.length - 1; i >= 0; i -= 1) {
|
|
|
|
|
const current = cloneDisplayValues[i];
|
|
|
|
|
|
|
|
|
|
if (!current.disabled) {
|
|
|
|
|
cloneDisplayValues.splice(i, 1);
|
|
|
|
|
removedDisplayValue = current;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (removedDisplayValue) {
|
|
|
|
|
props.onDisplayValuesChange(cloneDisplayValues, {
|
|
|
|
|
type: 'remove',
|
|
|
|
|
values: [removedDisplayValue],
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (mergedOpen.value && listRef.value) {
|
|
|
|
|
listRef.value.onKeydown(event, ...rest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
props.onKeydown?.(event, ...rest);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// KeyUp
|
|
|
|
|
const onInternalKeyUp: KeyboardEventHandler = (event: KeyboardEvent, ...rest) => {
|
|
|
|
|
if (mergedOpen.value && listRef.value) {
|
|
|
|
|
listRef.value.onKeyup(event, ...rest);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.onKeyup) {
|
|
|
|
|
props.onKeyup(event, ...rest);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============================ Selector ============================
|
|
|
|
|
const onSelectorRemove = (val: DisplayValueType) => {
|
|
|
|
|
const newValues = props.displayValues.filter(i => i !== val);
|
|
|
|
|
|
|
|
|
|
props.onDisplayValuesChange(newValues, {
|
|
|
|
|
type: 'remove',
|
|
|
|
|
values: [val],
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ========================== Focus / Blur ==========================
|
|
|
|
|
/** Record real focus status */
|
|
|
|
|
const focusRef = ref(false);
|
|
|
|
|
|
|
|
|
|
const onContainerFocus: FocusEventHandler = (...args) => {
|
|
|
|
|
setMockFocused(true);
|
|
|
|
|
|
|
|
|
|
if (!props.disabled) {
|
|
|
|
|
if (props.onFocus && !focusRef.value) {
|
|
|
|
|
props.onFocus(...args);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// `showAction` should handle `focus` if set
|
|
|
|
|
if (props.showAction && props.showAction.includes('focus')) {
|
|
|
|
|
onToggleOpen(true);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
focusRef.value = true;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const onContainerBlur: FocusEventHandler = (...args) => {
|
|
|
|
|
setMockFocused(false, () => {
|
|
|
|
|
focusRef.value = false;
|
|
|
|
|
onToggleOpen(false);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (props.disabled) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const searchVal = mergedSearchValue.value;
|
|
|
|
|
if (searchVal) {
|
|
|
|
|
// `tags` mode should move `searchValue` into values
|
|
|
|
|
if (props.mode === 'tags') {
|
|
|
|
|
props.onSearch(searchVal, { source: 'submit' });
|
|
|
|
|
} else if (props.mode === 'multiple') {
|
|
|
|
|
// `multiple` mode only clean the search value but not trigger event
|
|
|
|
|
props.onSearch('', {
|
|
|
|
|
source: 'blur',
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (props.onBlur) {
|
|
|
|
|
props.onBlur(...args);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
provide('VCSelectContainerEvent', {
|
|
|
|
|
focus: onContainerFocus,
|
|
|
|
|
blur: onContainerBlur,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Give focus back of Select
|
|
|
|
|
const activeTimeoutIds: any[] = [];
|
|
|
|
|
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId));
|
|
|
|
|
activeTimeoutIds.splice(0, activeTimeoutIds.length);
|
|
|
|
|
});
|
|
|
|
|
onBeforeUnmount(() => {
|
|
|
|
|
activeTimeoutIds.forEach(timeoutId => clearTimeout(timeoutId));
|
|
|
|
|
activeTimeoutIds.splice(0, activeTimeoutIds.length);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const onInternalMouseDown: MouseEventHandler = (event, ...restArgs) => {
|
|
|
|
|
const { target } = event;
|
|
|
|
|
const popupElement: HTMLDivElement = triggerRef.value?.getPopupElement();
|
|
|
|
|
|
|
|
|
|
// We should give focus back to selector if clicked item is not focusable
|
|
|
|
|
if (popupElement && popupElement.contains(target as HTMLElement)) {
|
|
|
|
|
const timeoutId: any = setTimeout(() => {
|
|
|
|
|
const index = activeTimeoutIds.indexOf(timeoutId);
|
|
|
|
|
if (index !== -1) {
|
|
|
|
|
activeTimeoutIds.splice(index, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cancelSetMockFocused();
|
|
|
|
|
|
|
|
|
|
if (!mobile.value && !popupElement.contains(document.activeElement)) {
|
|
|
|
|
selectorRef.value?.focus();
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
activeTimeoutIds.push(timeoutId);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
props.onMousedown?.(event, ...restArgs);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// ============================= Dropdown ==============================
|
|
|
|
|
const containerWidth = ref<number>(null);
|
|
|
|
|
const instance = getCurrentInstance();
|
|
|
|
|
const onPopupMouseEnter = () => {
|
|
|
|
|
// We need force update here since popup dom is render async
|
|
|
|
|
instance.update();
|
|
|
|
|
};
|
|
|
|
|
onMounted(() => {
|
|
|
|
|
watch(
|
|
|
|
|
triggerOpen,
|
|
|
|
|
() => {
|
|
|
|
|
if (triggerOpen.value) {
|
|
|
|
|
const newWidth = Math.ceil(containerRef.value?.offsetWidth);
|
|
|
|
|
if (containerWidth.value !== newWidth && !Number.isNaN(newWidth)) {
|
|
|
|
|
containerWidth.value = newWidth;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
{ immediate: true },
|
|
|
|
|
);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// Close when click on non-select element
|
|
|
|
|
useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen);
|
|
|
|
|
useProvideBaseSelectProps(
|
|
|
|
|
toReactive({
|
|
|
|
|
...toRefs(props),
|
|
|
|
|
open: mergedOpen,
|
|
|
|
|
triggerOpen,
|
|
|
|
|
showSearch: mergedShowSearch,
|
|
|
|
|
multiple,
|
|
|
|
|
toggleOpen: onToggleOpen,
|
|
|
|
|
} as unknown as BaseSelectContextProps),
|
|
|
|
|
);
|
|
|
|
|
return () => {
|
|
|
|
|
const {
|
|
|
|
|
prefixCls,
|
|
|
|
|
id,
|
|
|
|
|
|
|
|
|
|
open,
|
|
|
|
|
defaultOpen,
|
|
|
|
|
|
|
|
|
|
mode,
|
|
|
|
|
|
|
|
|
|
// Search related
|
|
|
|
|
showSearch,
|
|
|
|
|
searchValue,
|
|
|
|
|
onSearch,
|
|
|
|
|
|
|
|
|
|
// Icons
|
|
|
|
|
allowClear,
|
|
|
|
|
clearIcon,
|
|
|
|
|
showArrow,
|
|
|
|
|
inputIcon,
|
|
|
|
|
|
|
|
|
|
// Others
|
|
|
|
|
disabled,
|
|
|
|
|
loading,
|
|
|
|
|
getInputElement,
|
|
|
|
|
getPopupContainer,
|
|
|
|
|
placement,
|
|
|
|
|
|
|
|
|
|
// Dropdown
|
|
|
|
|
animation,
|
|
|
|
|
transitionName,
|
|
|
|
|
dropdownStyle,
|
|
|
|
|
dropdownClassName,
|
|
|
|
|
dropdownMatchSelectWidth,
|
|
|
|
|
dropdownRender,
|
|
|
|
|
dropdownAlign,
|
|
|
|
|
showAction,
|
|
|
|
|
direction,
|
|
|
|
|
|
|
|
|
|
// Tags
|
|
|
|
|
tokenSeparators,
|
|
|
|
|
tagRender,
|
2022-01-27 07:12:15 +00:00
|
|
|
|
optionLabelRender,
|
2022-01-21 13:58:10 +00:00
|
|
|
|
|
|
|
|
|
// Events
|
|
|
|
|
onPopupScroll,
|
|
|
|
|
onDropdownVisibleChange,
|
|
|
|
|
onFocus,
|
|
|
|
|
onBlur,
|
|
|
|
|
onKeyup,
|
|
|
|
|
onKeydown,
|
|
|
|
|
onMousedown,
|
|
|
|
|
|
|
|
|
|
onClear,
|
|
|
|
|
omitDomProps,
|
|
|
|
|
getRawInputElement,
|
|
|
|
|
displayValues,
|
|
|
|
|
onDisplayValuesChange,
|
|
|
|
|
emptyOptions,
|
|
|
|
|
activeDescendantId,
|
|
|
|
|
activeValue,
|
|
|
|
|
OptionList,
|
|
|
|
|
|
|
|
|
|
...restProps
|
|
|
|
|
} = { ...props, ...attrs } as BaseSelectProps;
|
|
|
|
|
// ============================= Input ==============================
|
|
|
|
|
// Only works in `combobox`
|
|
|
|
|
const customizeInputElement: any =
|
|
|
|
|
(mode === 'combobox' && getInputElement && getInputElement()) || null;
|
|
|
|
|
|
|
|
|
|
// Used for customize replacement for `vc-cascader`
|
|
|
|
|
const customizeRawInputElement: any =
|
|
|
|
|
typeof getRawInputElement === 'function' && getRawInputElement();
|
|
|
|
|
const domProps = {
|
|
|
|
|
...restProps,
|
|
|
|
|
} as Omit<keyof typeof restProps, typeof DEFAULT_OMIT_PROPS[number]>;
|
|
|
|
|
|
|
|
|
|
// Used for raw custom input trigger
|
|
|
|
|
let onTriggerVisibleChange: null | ((newOpen: boolean) => void);
|
|
|
|
|
if (customizeRawInputElement) {
|
|
|
|
|
onTriggerVisibleChange = (newOpen: boolean) => {
|
|
|
|
|
onToggleOpen(newOpen);
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
DEFAULT_OMIT_PROPS.forEach(propName => {
|
|
|
|
|
delete domProps[propName];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
omitDomProps?.forEach(propName => {
|
|
|
|
|
delete domProps[propName];
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// ============================= Arrow ==============================
|
|
|
|
|
const mergedShowArrow =
|
|
|
|
|
showArrow !== undefined ? showArrow : loading || (!multiple.value && mode !== 'combobox');
|
|
|
|
|
let arrowNode: VNode | JSX.Element;
|
|
|
|
|
|
|
|
|
|
if (mergedShowArrow) {
|
|
|
|
|
arrowNode = (
|
|
|
|
|
<TransBtn
|
|
|
|
|
class={classNames(`${prefixCls}-arrow`, {
|
|
|
|
|
[`${prefixCls}-arrow-loading`]: loading,
|
|
|
|
|
})}
|
|
|
|
|
customizeIcon={inputIcon}
|
|
|
|
|
customizeIconProps={{
|
|
|
|
|
loading,
|
|
|
|
|
searchValue: mergedSearchValue.value,
|
|
|
|
|
open: mergedOpen.value,
|
|
|
|
|
focused: mockFocused.value,
|
|
|
|
|
showSearch: mergedShowSearch.value,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ============================= Clear ==============================
|
|
|
|
|
let clearNode: VNode | JSX.Element;
|
|
|
|
|
const onClearMouseDown: MouseEventHandler = () => {
|
|
|
|
|
onClear?.();
|
|
|
|
|
|
|
|
|
|
onDisplayValuesChange([], {
|
|
|
|
|
type: 'clear',
|
|
|
|
|
values: displayValues,
|
|
|
|
|
});
|
|
|
|
|
onInternalSearch('', false, false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
if (!disabled && allowClear && (displayValues.length || mergedSearchValue.value)) {
|
|
|
|
|
clearNode = (
|
|
|
|
|
<TransBtn
|
|
|
|
|
class={`${prefixCls}-clear`}
|
|
|
|
|
onMousedown={onClearMouseDown}
|
|
|
|
|
customizeIcon={clearIcon}
|
|
|
|
|
>
|
|
|
|
|
×
|
|
|
|
|
</TransBtn>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// =========================== OptionList ===========================
|
|
|
|
|
const optionList = (
|
|
|
|
|
<OptionList
|
|
|
|
|
ref={listRef}
|
|
|
|
|
v-slots={{ ...legacyTreeSelectContext.customSlots, option: slots.option }}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// ============================= Select =============================
|
|
|
|
|
const mergedClassName = classNames(prefixCls, attrs.class, {
|
|
|
|
|
[`${prefixCls}-focused`]: mockFocused.value,
|
|
|
|
|
[`${prefixCls}-multiple`]: multiple.value,
|
|
|
|
|
[`${prefixCls}-single`]: !multiple.value,
|
|
|
|
|
[`${prefixCls}-allow-clear`]: allowClear,
|
|
|
|
|
[`${prefixCls}-show-arrow`]: mergedShowArrow,
|
|
|
|
|
[`${prefixCls}-disabled`]: disabled,
|
|
|
|
|
[`${prefixCls}-loading`]: loading,
|
|
|
|
|
[`${prefixCls}-open`]: mergedOpen.value,
|
|
|
|
|
[`${prefixCls}-customize-input`]: customizeInputElement,
|
|
|
|
|
[`${prefixCls}-show-search`]: mergedShowSearch.value,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
// >>> Selector
|
|
|
|
|
const selectorNode = (
|
|
|
|
|
<SelectTrigger
|
|
|
|
|
ref={triggerRef}
|
|
|
|
|
disabled={disabled}
|
|
|
|
|
prefixCls={prefixCls}
|
|
|
|
|
visible={triggerOpen.value}
|
|
|
|
|
popupElement={optionList}
|
|
|
|
|
containerWidth={containerWidth.value}
|
|
|
|
|
animation={animation}
|
|
|
|
|
transitionName={transitionName}
|
|
|
|
|
dropdownStyle={dropdownStyle}
|
|
|
|
|
dropdownClassName={dropdownClassName}
|
|
|
|
|
direction={direction}
|
|
|
|
|
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
|
|
|
|
|
dropdownRender={dropdownRender}
|
|
|
|
|
dropdownAlign={dropdownAlign}
|
|
|
|
|
placement={placement}
|
|
|
|
|
getPopupContainer={getPopupContainer}
|
|
|
|
|
empty={emptyOptions}
|
|
|
|
|
getTriggerDOMNode={() => selectorDomRef.current}
|
|
|
|
|
onPopupVisibleChange={onTriggerVisibleChange}
|
|
|
|
|
onPopupMouseEnter={onPopupMouseEnter}
|
|
|
|
|
v-slots={{
|
|
|
|
|
default: () => {
|
|
|
|
|
return customizeRawInputElement ? (
|
|
|
|
|
isValidElement(customizeRawInputElement) &&
|
|
|
|
|
cloneElement(
|
|
|
|
|
customizeRawInputElement,
|
|
|
|
|
{
|
|
|
|
|
ref: selectorDomRef,
|
|
|
|
|
},
|
|
|
|
|
false,
|
|
|
|
|
true,
|
|
|
|
|
)
|
|
|
|
|
) : (
|
|
|
|
|
<Selector
|
|
|
|
|
{...props}
|
|
|
|
|
domRef={selectorDomRef}
|
|
|
|
|
prefixCls={prefixCls}
|
|
|
|
|
inputElement={customizeInputElement}
|
|
|
|
|
ref={selectorRef}
|
|
|
|
|
id={id}
|
|
|
|
|
showSearch={mergedShowSearch.value}
|
|
|
|
|
mode={mode}
|
|
|
|
|
activeDescendantId={activeDescendantId}
|
|
|
|
|
tagRender={tagRender}
|
2022-01-27 07:12:15 +00:00
|
|
|
|
optionLabelRender={optionLabelRender}
|
2022-01-21 13:58:10 +00:00
|
|
|
|
values={displayValues}
|
|
|
|
|
open={mergedOpen.value}
|
|
|
|
|
onToggleOpen={onToggleOpen}
|
|
|
|
|
activeValue={activeValue}
|
|
|
|
|
searchValue={mergedSearchValue.value}
|
|
|
|
|
onSearch={onInternalSearch}
|
|
|
|
|
onSearchSubmit={onInternalSearchSubmit}
|
|
|
|
|
onRemove={onSelectorRemove}
|
|
|
|
|
tokenWithEnter={tokenWithEnter.value}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
},
|
|
|
|
|
}}
|
|
|
|
|
></SelectTrigger>
|
|
|
|
|
);
|
|
|
|
|
// >>> Render
|
|
|
|
|
let renderNode: VNode | JSX.Element;
|
|
|
|
|
|
|
|
|
|
// Render raw
|
|
|
|
|
if (customizeRawInputElement) {
|
|
|
|
|
renderNode = selectorNode;
|
|
|
|
|
} else {
|
|
|
|
|
renderNode = (
|
|
|
|
|
<div
|
|
|
|
|
{...domProps}
|
|
|
|
|
class={mergedClassName}
|
|
|
|
|
ref={containerRef}
|
|
|
|
|
onMousedown={onInternalMouseDown}
|
|
|
|
|
onKeydown={onInternalKeyDown}
|
|
|
|
|
onKeyup={onInternalKeyUp}
|
|
|
|
|
// onFocus={onContainerFocus}
|
|
|
|
|
// onBlur={onContainerBlur}
|
|
|
|
|
>
|
|
|
|
|
{mockFocused.value && !mergedOpen.value && (
|
|
|
|
|
<span
|
|
|
|
|
style={{
|
|
|
|
|
width: 0,
|
|
|
|
|
height: 0,
|
|
|
|
|
display: 'flex',
|
|
|
|
|
overflow: 'hidden',
|
|
|
|
|
opacity: 0,
|
|
|
|
|
}}
|
|
|
|
|
aria-live="polite"
|
|
|
|
|
>
|
|
|
|
|
{/* Merge into one string to make screen reader work as expect */}
|
|
|
|
|
{`${displayValues
|
|
|
|
|
.map(({ label, value }) =>
|
|
|
|
|
['number', 'string'].includes(typeof label) ? label : value,
|
|
|
|
|
)
|
|
|
|
|
.join(', ')}`}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
{selectorNode}
|
|
|
|
|
|
|
|
|
|
{arrowNode}
|
|
|
|
|
{clearNode}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
return renderNode;
|
|
|
|
|
};
|
|
|
|
|
},
|
|
|
|
|
});
|