ant-design-vue/components/vc-select/generate.tsx

1288 lines
41 KiB
Vue
Raw Normal View History

2020-10-07 14:49:01 +00:00
/**
* 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';
2021-08-21 08:25:55 +00:00
import type { Mode, RenderDOMFunc, OnActiveValue } from './interface';
2021-06-26 01:35:40 +00:00
import type {
2020-10-07 14:49:01 +00:00
GetLabeledValue,
FilterOptions,
FilterFunc,
DefaultValueType,
RawValueType,
Key,
DisplayLabelValueType,
FlattenOptionsType,
SingleType,
OnClear,
SelectSource,
CustomTagProps,
} from './interface/generator';
2021-06-26 01:35:40 +00:00
import { INTERNAL_PROPS_MARK } from './interface/generator';
import type { OptionListProps } from './OptionList';
2020-10-07 14:49:01 +00:00
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';
2021-08-21 08:25:55 +00:00
import type { CSSProperties, DefineComponent, PropType, VNode, VNodeChild } from 'vue';
2020-10-07 14:49:01 +00:00
import {
computed,
defineComponent,
onBeforeUnmount,
onMounted,
provide,
ref,
watch,
watchEffect,
2020-10-07 14:49:01 +00:00
} from 'vue';
import createRef from '../_util/createRef';
2021-08-21 08:25:55 +00:00
import PropTypes from '../_util/vue-types';
import warning from '../_util/warning';
2021-06-22 02:47:33 +00:00
import isMobile from '../vc-util/isMobile';
2020-10-07 14:49:01 +00:00
const DEFAULT_OMIT_PROPS = [
'children',
'removeIcon',
'placeholder',
'autofocus',
'maxTagCount',
'maxTagTextLength',
'maxTagPlaceholder',
'choiceTransitionName',
'onInputKeyDown',
2021-06-22 02:47:33 +00:00
'tabindex',
2020-10-07 14:49:01 +00:00
];
2021-08-21 08:25:55 +00:00
export function selectBaseProps<OptionType, ValueType>() {
return {
prefixCls: String,
id: String,
// Options
options: { type: Array as PropType<OptionType[]> },
mode: { type: String as PropType<Mode> },
// Value
value: { type: [String, Number, Object, Array] as PropType<ValueType> },
defaultValue: { type: [String, Number, Object, Array] as PropType<ValueType> },
labelInValue: { type: Boolean, default: undefined },
// 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<boolean | FilterFunc<OptionType>>,
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<OnClear> },
// Icons
allowClear: { type: Boolean, default: undefined },
clearIcon: PropTypes.any,
showArrow: { type: Boolean, default: undefined },
inputIcon: PropTypes.VNodeChild,
removeIcon: PropTypes.VNodeChild,
menuItemSelectedIcon: PropTypes.VNodeChild,
// Dropdown
open: { type: Boolean, default: undefined },
defaultOpen: { type: Boolean, default: undefined },
listHeight: Number,
listItemHeight: Number,
dropdownStyle: { type: Function as PropType<CSSProperties> },
dropdownClassName: String,
dropdownMatchSelectWidth: {
type: [Boolean, Number] as PropType<boolean | number>,
default: undefined,
},
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<RenderDOMFunc> },
direction: String,
// 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<number | 'responsive'> },
maxTagPlaceholder: PropTypes.any,
tokenSeparators: { type: Array as PropType<string[]> },
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<ValueType>, option: OptionType) => void>,
},
onDeselect: {
type: Function as PropType<(value: SingleType<ValueType>, 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: Array,
2020-10-07 14:49:01 +00:00
};
}
2021-08-21 08:25:55 +00:00
class Helper<T1, T2> {
SelectBaseProps = selectBaseProps<T1, T2>();
}
type FuncReturnType<T1, T2> = Helper<T1, T2>['SelectBaseProps'];
export type SelectProps<T1, T2> = FuncReturnType<T1, T2>;
export interface GenerateConfig<OptionType extends object> {
2020-10-07 14:49:01 +00:00
prefixCls: string;
components: {
2021-08-21 08:25:55 +00:00
optionList: DefineComponent<
Omit<OptionListProps<OptionType>, 'options'> & { options?: OptionType[] }
>;
2020-10-07 14:49:01 +00:00
};
2021-08-21 08:25:55 +00:00
/** Convert jsx tree into `OptionType[]` */
convertChildrenToData: (children: VNodeChild | JSX.Element) => OptionType[];
2020-10-07 14:49:01 +00:00
/** Flatten nest options into raw option list */
2021-08-21 08:25:55 +00:00
flattenOptions: (options: OptionType[], props: any) => FlattenOptionsType<OptionType>;
2020-10-07 14:49:01 +00:00
/** Convert single raw value into { label, value } format. Will be called by each value */
2021-08-21 08:25:55 +00:00
getLabeledValue: GetLabeledValue<FlattenOptionsType<OptionType>>;
filterOptions: FilterOptions<OptionType[]>;
2020-10-07 14:49:01 +00:00
findValueOption: // Need still support legacy ts api
2021-08-21 08:25:55 +00:00
| ((values: RawValueType[], options: FlattenOptionsType<OptionType>) => OptionType[])
2020-10-07 14:49:01 +00:00
// New API add prevValueOptions support
| ((
values: RawValueType[],
2021-08-21 08:25:55 +00:00
options: FlattenOptionsType<OptionType>,
info?: { prevValueOptions?: OptionType[][] },
) => OptionType[]);
2020-10-07 14:49:01 +00:00
/** Check if a value is disabled */
2021-08-21 08:25:55 +00:00
isValueDisabled: (value: RawValueType, options: FlattenOptionsType<OptionType>) => boolean;
2020-10-07 14:49:01 +00:00
warningProps?: (props: any) => void;
fillOptionsWithMissingValue?: (
2021-08-21 08:25:55 +00:00
options: OptionType[],
2020-10-07 14:49:01 +00:00
value: DefaultValueType,
optionLabelProp: string,
labelInValue: boolean,
2021-08-21 08:25:55 +00:00
) => OptionType[];
2020-10-07 14:49:01 +00:00
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<
2021-08-21 08:25:55 +00:00
OptionType extends {
2020-10-07 14:49:01 +00:00
value?: RawValueType;
2021-08-21 08:25:55 +00:00
label?: any;
2020-10-07 14:49:01 +00:00
key?: Key;
disabled?: boolean;
2021-08-21 08:25:55 +00:00
},
>(config: GenerateConfig<OptionType>) {
2020-10-07 14:49:01 +00:00
const {
prefixCls: defaultPrefixCls,
components: { optionList: OptionList },
convertChildrenToData,
flattenOptions,
getLabeledValue,
filterOptions,
isValueDisabled,
findValueOption,
warningProps,
fillOptionsWithMissingValue,
omitDOMProps,
2020-10-28 07:31:45 +00:00
} = config as any;
2021-08-21 08:25:55 +00:00
const Select = defineComponent({
2020-10-07 14:49:01 +00:00
name: 'Select',
slots: ['option'],
2021-08-21 08:25:55 +00:00
inheritAttrs: false,
props: selectBaseProps<OptionType, DefaultValueType>(),
setup(props) {
const useInternalProps = computed(
() => props.internalProps && props.internalProps.mark === INTERNAL_PROPS_MARK,
);
warning(
props.optionFilterProp !== 'children',
'Select',
'optionFilterProp not support children, please use label instead',
);
2020-10-07 14:49:01 +00:00
const containerRef = ref(null);
const triggerRef = ref(null);
const selectorRef = ref(null);
const listRef = ref(null);
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
2020-10-13 09:46:52 +00:00
const mergedOptionLabelProp = computed(() => {
2020-10-07 14:49:01 +00:00
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',
);
2021-06-22 02:47:33 +00:00
const mobile = ref(false);
onMounted(() => {
mobile.value = isMobile();
});
2020-10-07 14:49:01 +00:00
// ============================== Ref ===============================
const selectorDomRef = createRef();
2021-06-22 05:26:19 +00:00
const mergedValue = ref();
2020-10-07 14:49:01 +00:00
watch(
2021-06-22 05:26:19 +00:00
() => props.value,
2020-10-07 14:49:01 +00:00
() => {
mergedValue.value = props.value !== undefined ? props.value : props.defaultValue;
},
{ immediate: true },
);
// ============================= Value ==============================
/** Unique raw values */
2021-06-22 02:47:33 +00:00
const mergedRawValueArr = computed(() =>
2020-10-07 14:49:01 +00:00
toInnerValue(mergedValue.value, {
labelInValue: mergedLabelInValue.value,
combobox: props.mode === 'combobox',
}),
);
2021-06-22 02:47:33 +00:00
const mergedRawValue = computed(() => mergedRawValueArr.value[0]);
const mergedValueMap = computed(() => mergedRawValueArr.value[1]);
2020-10-07 14:49:01 +00:00
/** 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(null);
const setActiveValue = (val: string) => {
activeValue.value = val;
};
const innerSearchValue = ref('');
const setInnerSearchValue = (val: string) => {
innerSearchValue.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;
});
2021-08-21 08:25:55 +00:00
const mergedOptions = computed((): OptionType[] => {
2021-06-23 15:08:16 +00:00
let newOptions = props.options;
if (newOptions === undefined) {
newOptions = convertChildrenToData(props.children);
}
/**
* `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,
);
}
2021-08-21 08:25:55 +00:00
return newOptions || ([] as OptionType[]);
2021-06-23 15:08:16 +00:00
});
2020-10-07 14:49:01 +00:00
const mergedFlattenOptions = computed(() => flattenOptions(mergedOptions.value, props));
2021-06-22 02:47:33 +00:00
const getValueOption = useCacheOptions(mergedFlattenOptions);
2020-10-07 14:49:01 +00:00
// Display options for OptionList
2021-08-21 08:25:55 +00:00
const displayOptions = computed<OptionType[]>(() => {
2020-11-07 14:43:31 +00:00
if (!mergedSearchValue.value || !mergedShowSearch.value) {
2021-08-21 08:25:55 +00:00
return [...mergedOptions.value] as OptionType[];
2020-10-07 14:49:01 +00:00
}
const { optionFilterProp = 'value', mode, filterOption } = props;
2021-08-21 08:25:55 +00:00
const filteredOptions: OptionType[] = filterOptions(
2020-10-07 14:49:01 +00:00
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__',
2021-08-21 08:25:55 +00:00
} as OptionType);
2020-10-07 14:49:01 +00:00
}
2021-06-22 02:47:33 +00:00
if (props.filterSort && Array.isArray(filteredOptions)) {
2021-08-21 08:25:55 +00:00
return ([...filteredOptions] as OptionType[]).sort(props.filterSort);
2021-06-22 02:47:33 +00:00
}
2020-10-07 14:49:01 +00:00
return filteredOptions;
});
const displayFlattenOptions = computed(() => flattenOptions(displayOptions.value, props));
2020-10-08 14:51:09 +00:00
onMounted(() => {
watch(
mergedSearchValue,
() => {
if (listRef.value && listRef.value.scrollTo) {
listRef.value.scrollTo(0);
}
},
{ flush: 'post', immediate: true },
);
});
2020-10-07 14:49:01 +00:00
// ============================ Selector ============================
let displayValues = computed<DisplayLabelValueType[]>(() => {
const tmpValues = mergedRawValue.value.map((val: RawValueType) => {
const valueOptions = getValueOption([val]);
const displayValue = getLabeledValue(val, {
options: valueOptions,
2021-06-22 02:47:33 +00:00
prevValueMap: mergedValueMap.value,
2020-10-07 14:49:01 +00:00
labelInValue: mergedLabelInValue.value,
optionLabelProp: mergedOptionLabelProp.value,
});
return {
...displayValue,
disabled: isValueDisabled(val, valueOptions),
};
});
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)[0];
const { internalProps = {} } = props;
if (!internalProps.skipTriggerSelect) {
2020-10-07 14:49:01 +00:00
// Skip trigger `onSelect` or `onDeselect` if configured
2021-06-23 15:08:16 +00:00
const selectValue = (
mergedLabelInValue.value
? getLabeledValue(newValue, {
options: newValueOption,
prevValueMap: mergedValueMap.value,
labelInValue: mergedLabelInValue.value,
optionLabelProp: mergedOptionLabelProp.value,
})
: newValue
) as SingleType<ValueType>;
2020-10-07 14:49:01 +00:00
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);
2020-10-07 14:49:01 +00:00
}
}
};
// We need cache options here in case user update the option list
const prevValueOptions = ref([]);
const setPrevValueOptions = (val: any[]) => {
prevValueOptions.value = val;
};
const triggerChange = (newRawValues: RawValueType[]) => {
if (
useInternalProps.value &&
props.internalProps &&
props.internalProps.skipTriggerChange
) {
2020-10-07 14:49:01 +00:00
return;
}
const newRawValuesOptions = getValueOption(newRawValues);
2021-08-21 08:25:55 +00:00
const outValues = toOuterValues<FlattenOptionsType<OptionType>>(Array.from(newRawValues), {
2020-10-07 14:49:01 +00:00
labelInValue: mergedLabelInValue.value,
2021-08-21 08:25:55 +00:00
options: newRawValuesOptions as any,
2020-10-07 14:49:01 +00:00
getLabeledValue,
2021-06-22 02:47:33 +00:00
prevValueMap: mergedValueMap.value,
2020-10-07 14:49:01 +00:00
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,
});
// 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]);
}
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<RawValueType>;
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 ==============================
2020-10-26 14:14:00 +00:00
const initOpen = props.open !== undefined ? props.open : props.defaultOpen;
const innerOpen = ref(initOpen);
const mergedOpen = ref(initOpen);
2020-10-07 14:49:01 +00:00
const setInnerOpen = (val: boolean) => {
2020-10-26 14:14:00 +00:00
innerOpen.value = props.open !== undefined ? props.open : val;
mergedOpen.value = innerOpen.value;
2020-10-07 14:49:01 +00:00
};
watch(
2020-10-26 14:14:00 +00:00
() => props.open,
2020-10-07 14:49:01 +00:00
() => {
2020-10-26 14:14:00 +00:00
setInnerOpen(props.open);
2020-10-07 14:49:01 +00:00
},
);
// 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;
}
});
2020-10-07 14:49:01 +00:00
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);
}
}
};
2020-10-10 05:57:37 +00:00
useSelectTriggerControl([containerRef, triggerRef], triggerOpen, onToggleOpen);
2020-10-07 14:49:01 +00:00
// ============================= Search =============================
const triggerSearch = (searchText: string, fromTyping: boolean, isCompositing: boolean) => {
let ret = true;
let newSearchText = searchText;
const preSearchValue = mergedSearchValue.value;
2020-10-07 14:49:01 +00:00
setActiveValue(null);
// Check if match the `tokenSeparators`
const patchLabels: string[] = isCompositing
? null
: getSeparatedContent(searchText, props.tokenSeparators);
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 }) => data[mergedOptionLabelProp.value] === label,
);
return item ? item.data.value : null;
})
.filter((val: RawValueType) => val !== null);
}
const newRawValues = Array.from(
new Set<RawValueType>([...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) {
2020-10-07 14:49:01 +00:00
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) => {
2021-06-22 02:47:33 +00:00
// prevent empty tags from appearing when you click the Enter button
if (!searchText || !searchText.trim()) {
return;
}
2020-10-07 14:49:01 +00:00
const newRawValues = Array.from(
new Set<RawValueType>([...mergedRawValue.value, searchText]),
);
triggerChange(newRawValues);
newRawValues.forEach(newRawValue => {
triggerSelect(newRawValue, true, 'input');
});
setInnerSearchValue('');
};
// Close dropdown when disabled change
watch(
2021-06-22 05:26:19 +00:00
() => props.disabled,
2020-10-07 14:49:01 +00:00
() => {
if (innerOpen.value && !!props.disabled) {
setInnerOpen(false);
}
},
2020-10-08 14:51:09 +00:00
{ immediate: true },
2020-10-07 14:49:01 +00:00
);
// Close will clean up single mode search text
2020-10-08 14:51:09 +00:00
watch(
mergedOpen,
() => {
if (!mergedOpen.value && !isMultiple.value && props.mode !== 'combobox') {
triggerSearch('', false, false);
}
},
{ immediate: true },
);
2020-10-07 14:49:01 +00:00
// ============================ 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;
2021-06-22 02:47:33 +00:00
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);
}
2020-10-07 14:49:01 +00:00
}
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
2021-08-21 08:25:55 +00:00
const onInternalKeyUp = (event: KeyboardEvent) => {
2020-10-07 14:49:01 +00:00
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')) {
2020-10-07 14:49:01 +00:00
onToggleOpen(true);
}
}
focusRef.value = true;
};
const onContainerBlur = (...args: any[]) => {
setMockFocused(false, () => {
focusRef.value = false;
onToggleOpen(false);
});
if (props.disabled) {
return;
}
const serachVal = mergedSearchValue.value;
if (serachVal) {
2020-10-07 14:49:01 +00:00
// `tags` mode should move `searchValue` into values
if (props.mode === 'tags') {
triggerSearch('', false, false);
triggerChange(Array.from(new Set([...mergedRawValue.value, serachVal])));
2020-10-07 14:49:01 +00:00
} 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(() => {
2021-02-25 09:03:19 +00:00
activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId));
2020-10-07 14:49:01 +00:00
activeTimeoutIds.splice(0, activeTimeoutIds.length);
});
onBeforeUnmount(() => {
2021-02-25 09:03:19 +00:00
activeTimeoutIds.forEach(timeoutId => window.clearTimeout(timeoutId));
2020-10-07 14:49:01 +00:00
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)) {
2021-02-25 07:37:09 +00:00
const timeoutId = window.setTimeout(() => {
2020-10-07 14:49:01 +00:00
const index = activeTimeoutIds.indexOf(timeoutId);
if (index !== -1) {
activeTimeoutIds.splice(index, 1);
}
cancelSetMockFocused();
2021-06-22 02:47:33 +00:00
if (!mobile.value && !popupElement.contains(document.activeElement)) {
2020-10-07 14:49:01 +00:00
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);
2020-10-08 14:51:09 +00:00
onMounted(() => {
watch(
triggerOpen,
() => {
if (triggerOpen.value) {
const newWidth = Math.ceil(containerRef.value.offsetWidth);
2020-11-07 14:43:31 +00:00
if (containerWidth.value !== newWidth) {
2020-10-08 14:51:09 +00:00
containerWidth.value = newWidth;
}
}
},
{ immediate: true },
);
2020-10-07 14:49:01 +00:00
});
2020-10-08 14:51:09 +00:00
2020-10-07 14:49:01 +00:00
const focus = () => {
selectorRef.value.focus();
};
const blur = () => {
selectorRef.value.blur();
};
return {
focus,
blur,
2021-06-22 02:47:33 +00:00
scrollTo: listRef.value?.scrollTo,
2020-10-07 14:49:01 +00:00
tokenWithEnter,
mockFocused,
mergedId,
containerWidth,
onActiveValue,
accessibilityIndex,
mergedDefaultActiveFirstOption,
onInternalMouseDown,
onContainerFocus,
onContainerBlur,
onInternalKeyDown,
isMultiple,
mergedOpen,
displayOptions,
displayFlattenOptions,
rawValues,
onInternalOptionSelect,
onToggleOpen,
mergedSearchValue,
useInternalProps,
triggerChange,
triggerSearch,
mergedRawValue,
mergedShowSearch,
onInternalKeyUp,
triggerOpen,
mergedOptions,
onInternalSelectionSelect,
selectorDomRef,
displayValues,
activeValue,
onSearchSubmit,
containerRef,
listRef,
2020-10-10 05:57:37 +00:00
triggerRef,
2020-10-22 10:45:03 +00:00
selectorRef,
2020-10-07 14:49:01 +00:00
};
},
methods: {
// We need force update here since popup dom is render async
onPopupMouseEnter() {
2021-08-10 06:36:28 +00:00
(this as any).$forceUpdate();
2020-10-07 14:49:01 +00:00
},
},
render() {
const {
tokenWithEnter,
mockFocused,
mergedId,
containerWidth,
onActiveValue,
accessibilityIndex,
mergedDefaultActiveFirstOption,
onInternalMouseDown,
onInternalKeyDown,
isMultiple,
mergedOpen,
displayOptions,
displayFlattenOptions,
rawValues,
onInternalOptionSelect,
onToggleOpen,
mergedSearchValue,
onPopupMouseEnter,
useInternalProps,
triggerChange,
triggerSearch,
mergedRawValue,
mergedShowSearch,
onInternalKeyUp,
triggerOpen,
mergedOptions,
onInternalSelectionSelect,
selectorDomRef,
displayValues,
activeValue,
onSearchSubmit,
$slots: slots,
2020-10-10 05:57:37 +00:00
} = this as any;
2020-10-07 14:49:01 +00:00
const {
prefixCls = defaultPrefixCls,
2020-10-08 14:51:09 +00:00
id,
open,
defaultOpen,
2020-10-07 14:49:01 +00:00
options,
2020-10-08 14:51:09 +00:00
children,
2020-10-07 14:49:01 +00:00
mode,
2020-10-08 14:51:09 +00:00
value,
defaultValue,
labelInValue,
// Search related
showSearch,
inputValue,
searchValue,
filterOption,
2020-10-19 08:43:10 +00:00
optionFilterProp,
autoClearSearchValue,
2020-10-08 14:51:09 +00:00
onSearch,
2020-10-07 14:49:01 +00:00
// Icons
allowClear,
clearIcon,
showArrow,
inputIcon,
menuItemSelectedIcon,
// Others
disabled,
loading,
2020-10-08 14:51:09 +00:00
defaultActiveFirstOption,
2020-10-07 14:49:01 +00:00
notFoundContent = 'Not Found',
2020-10-08 14:51:09 +00:00
optionLabelProp,
backfill,
2020-10-07 14:49:01 +00:00
getInputElement,
getPopupContainer,
// Dropdown
listHeight = 200,
listItemHeight = 20,
animation,
transitionName,
virtual,
dropdownStyle,
dropdownClassName,
dropdownMatchSelectWidth,
dropdownRender,
dropdownAlign,
2020-10-19 08:43:10 +00:00
showAction,
2020-10-07 14:49:01 +00:00
direction,
2020-10-08 14:51:09 +00:00
// Tags
tokenSeparators,
2020-10-07 14:49:01 +00:00
tagRender,
// Events
onPopupScroll,
2020-10-08 14:51:09 +00:00
onDropdownVisibleChange,
onFocus,
onBlur,
onKeyup,
onKeydown,
onMousedown,
onChange,
onSelect,
onDeselect,
2020-10-07 14:49:01 +00:00
onClear,
internalProps = {},
...restProps
2021-08-21 08:25:55 +00:00
} = this.$props; //as SelectProps<OptionType[], ValueType>;
2020-10-07 14:49:01 +00:00
// ============================= Input ==============================
// Only works in `combobox`
2020-10-08 14:51:09 +00:00
const customizeInputElement: VNodeChild | JSX.Element =
2020-10-07 14:49:01 +00:00
(mode === 'combobox' && getInputElement && getInputElement()) || null;
const domProps = omitDOMProps ? omitDOMProps(restProps) : restProps;
DEFAULT_OMIT_PROPS.forEach(prop => {
delete domProps[prop];
});
const popupNode = (
<OptionList
ref="listRef"
prefixCls={prefixCls}
id={mergedId}
open={mergedOpen}
childrenAsData={!options}
options={displayOptions}
flattenOptions={displayFlattenOptions}
multiple={isMultiple}
values={rawValues}
height={listHeight}
itemHeight={listItemHeight}
onSelect={onInternalOptionSelect}
onToggleOpen={onToggleOpen}
onActiveValue={onActiveValue}
defaultActiveFirstOption={mergedDefaultActiveFirstOption}
notFoundContent={notFoundContent}
onScroll={onPopupScroll}
searchValue={mergedSearchValue}
menuItemSelectedIcon={menuItemSelectedIcon}
virtual={virtual !== false && dropdownMatchSelectWidth !== false}
onMouseenter={onPopupMouseEnter}
v-slots={{ option: slots.option }}
2020-10-07 14:49:01 +00:00
/>
);
// ============================= Clear ==============================
let clearNode: VNode | JSX.Element;
const onClearMouseDown = () => {
// Trigger internal `onClear` event
if (useInternalProps && internalProps.onClear) {
internalProps.onClear();
}
if (onClear) {
onClear();
}
triggerChange([]);
triggerSearch('', false, false);
};
if (!disabled && allowClear && (mergedRawValue.length || mergedSearchValue)) {
clearNode = (
<TransBtn
class={`${prefixCls}-clear`}
onMousedown={onClearMouseDown}
customizeIcon={clearIcon}
>
×
</TransBtn>
);
}
// ============================= Arrow ==============================
const mergedShowArrow =
showArrow !== undefined ? showArrow : loading || (!isMultiple && 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,
open: mergedOpen,
focused: mockFocused,
showSearch: mergedShowSearch,
}}
/>
);
}
// ============================ Warning =============================
if (process.env.NODE_ENV !== 'production' && warningProps) {
warningProps(this.$props);
}
// ============================= Render =============================
2021-08-21 08:25:55 +00:00
const mergedClassName = classNames(prefixCls, this.$attrs.class, {
2020-10-07 14:49:01 +00:00
[`${prefixCls}-focused`]: mockFocused,
[`${prefixCls}-multiple`]: isMultiple,
[`${prefixCls}-single`]: !isMultiple,
[`${prefixCls}-allow-clear`]: allowClear,
[`${prefixCls}-show-arrow`]: mergedShowArrow,
[`${prefixCls}-disabled`]: disabled,
[`${prefixCls}-loading`]: loading,
[`${prefixCls}-open`]: mergedOpen,
[`${prefixCls}-customize-input`]: customizeInputElement,
[`${prefixCls}-show-search`]: mergedShowSearch,
});
return (
<div
2021-08-21 08:25:55 +00:00
{...this.$attrs}
2020-10-07 14:49:01 +00:00
class={mergedClassName}
{...domProps}
ref="containerRef"
onMousedown={onInternalMouseDown}
onKeydown={onInternalKeyDown}
onKeyup={onInternalKeyUp}
// onFocus={onContainerFocus} // trigger by input
// onBlur={onContainerBlur} // trigger by input
>
{mockFocused && !mergedOpen && (
<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 */}
{`${mergedRawValue.join(', ')}`}
</span>
)}
<SelectTrigger
ref="triggerRef"
disabled={disabled}
prefixCls={prefixCls}
visible={triggerOpen}
popupElement={popupNode}
containerWidth={containerWidth}
animation={animation}
transitionName={transitionName}
dropdownStyle={dropdownStyle}
dropdownClassName={dropdownClassName}
direction={direction}
dropdownMatchSelectWidth={dropdownMatchSelectWidth}
2021-04-09 03:00:41 +00:00
dropdownRender={dropdownRender as any}
2020-10-07 14:49:01 +00:00
dropdownAlign={dropdownAlign}
getPopupContainer={getPopupContainer}
empty={!mergedOptions.length}
2021-04-09 03:00:41 +00:00
getTriggerDOMNode={() => selectorDomRef.current}
2020-10-07 14:49:01 +00:00
>
<Selector
{...this.$props}
domRef={selectorDomRef}
prefixCls={prefixCls}
inputElement={customizeInputElement}
ref="selectorRef"
id={mergedId}
showSearch={mergedShowSearch}
mode={mode}
accessibilityIndex={accessibilityIndex}
multiple={isMultiple}
tagRender={tagRender}
values={displayValues}
open={mergedOpen}
onToggleOpen={onToggleOpen}
searchValue={mergedSearchValue}
activeValue={activeValue}
onSearch={triggerSearch}
onSearchSubmit={onSearchSubmit}
onSelect={onInternalSelectionSelect}
tokenWithEnter={tokenWithEnter}
/>
</SelectTrigger>
{arrowNode}
{clearNode}
</div>
);
},
});
return Select;
}